contacts edit: a removed row takes its X-ABLabel with it
Phone, Email, and Address carry the label's parsed line rather than
its text, so a blanked row removes both lines instead of leaving the
label naming nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138CM3cNKnfJZ8LbDsCPRaf
change
commit fc7ad843119a8d31458dfac5f39d09afa9960cd4
author Claude <noreply@anthropic.com>
date
parent a882a6db
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index e60728a..f027eaf 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -172,9 +172,8 @@ module ProTacts
           next if submitted.nil? || submitted.strip == row.value.strip
 
           value = submitted.strip
-          vcard = vcard.substitute(
-            row.line.digest,
-            value.empty? ? [] : ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
+          vcard = value.empty? ? removed(vcard, row) : vcard.substitute(
+            row.line.digest, ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
           )
         end
 
@@ -241,7 +240,7 @@ module ProTacts
           next if submitted.nil? || address_unchanged?(address, submitted)
 
           line = address_line(address, submitted)
-          vcard = vcard.substitute(address.line.digest, line ? [line] : [])
+          vcard = line ? vcard.substitute(address.line.digest, [line]) : removed(vcard, address)
         end
 
         # The add dialog names an added address by its add index
@@ -254,6 +253,17 @@ module ProTacts
         vcard.insert(added.filter_map { address_line(nil, it) })
       end
 
+      # A row's lines gone from the card: its own, and the `X-ABLabel`
+      # sharing its group, which would otherwise be left naming nothing
+      # (docs/plans/2026-10-22-a-row-edits-its-label.md, "Removing a row
+      # removes its label").
+      #: (VCard vcard, Contact::Phone | Contact::Email | Contact::Address row) -> VCard
+      def self.removed(vcard, row)
+        vcard = vcard.substitute(row.line.digest, [])
+        label = row.label_line
+        label ? vcard.substitute(label.digest, []) : vcard
+      end
+
       # A field the editors post as a hash keyed by row: none where the
       # form rendered no such rows, and a raise for any other shape.
       # Web#card_form_params answers that shape as a 400 before a save
@@ -453,7 +463,7 @@ module ProTacts
         "N:#{components.join(";")}"
       end
 
-      private_class_method :edited_values, :edited_handles, :edited_addresses, :hash_field, :address_unchanged?,
+      private_class_method :edited_values, :edited_handles, :edited_addresses, :removed, :hash_field, :address_unchanged?,
                            :address_line, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
                            :n_line, :company_name, :person_name, :edited_organization
     end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 87d5d02..95c55b6 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -58,9 +58,11 @@ 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's `label` is the `X-ABLabel` sharing its property group,
-    # a different fact from its types
-    # (docs/plans/2026-09-18-phone-labels.md, "Reading"), every
+    # A row's `label_line` is the `X-ABLabel` sharing its property
+    # group, a different fact from its types
+    # (docs/plans/2026-09-18-phone-labels.md, "Reading"), carried as
+    # the line so that a save removing the row removes its label with
+    # it (docs/plans/2026-10-22-a-row-edits-its-label.md), every
     # shape's `line` is the parsed line it was read from, the address
     # a save names that row by
     # (docs/plans/2026-09-05-web-card-editor.md), a repeatable row's
@@ -71,9 +73,9 @@ 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, :label, :types, :line, :property, :lent_by)
+    Phone = Data.define(:value, :label_line, :types, :line, :property, :lent_by)
     # @rbs skip
-    Email = Data.define(:value, :types, :line, :property, :lent_by)
+    Email = Data.define(:value, :label_line, :types, :line, :property, :lent_by)
     # A handle: an IMPP (RFC 6350 section 6.4.3, from RFC 4770 for
     # 3.0) as Contacts.app spells one, the service its X-SERVICE-TYPE
     # parameter and the value the handle, with the URI scheme it sits
@@ -105,6 +107,7 @@ module ProTacts
       :postal_code,
       :country,
       :types,
+      :label_line,
       :line,
       :property,
       :lent_by,
@@ -118,6 +121,17 @@ 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
+    # 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
+    end
+
     # A handle's line in Contacts.app's spelling, the service in
     # X-SERVICE-TYPE and the handle under `x-apple:`
     # (docs/plans/2026-10-18-signal-handles.md), keeping the group and
@@ -405,15 +419,14 @@ module ProTacts
 
     #: () -> Array[Phone]
     def phones
-      labels = labels_by_group
+      labels = label_lines
       rows("TEL") { |property, line, lent_by|
         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:,
-          label: group && labels[group.downcase],
+          label_line: label_line_of(property, labels),
           types: property.types - %w[voice],
           line:,
           property:,
@@ -424,11 +437,19 @@ module ProTacts
 
     #: () -> Array[Email]
     def emails
+      labels = label_lines
       rows("EMAIL") { |property, line, lent_by|
         value = text_of(property)
         # RFC 2426 section 3.3.2: `internet` is the format every address
         # has by default, so it names none.
-        Email.new(value:, types: property.types - %w[internet], line:, property:, lent_by:) if value
+        Email.new(
+          value:,
+          label_line: label_line_of(property, labels),
+          types: property.types - %w[internet],
+          line:,
+          property:,
+          lent_by:,
+        ) if value
       }
     end
 
@@ -447,7 +468,10 @@ module ProTacts
 
     #: () -> Array[Address]
     def addresses
-      rows("ADR") { |property, line, lent_by| address_of(property, line:, lent_by:) }
+      labels = label_lines
+      rows("ADR") { |property, line, lent_by|
+        address_of(property, label_line: label_line_of(property, labels), line:, lent_by:)
+      }
     end
 
     # The card's NOTEs (RFC 2426 section 3.6.2), in text form. On the
@@ -549,29 +573,36 @@ module ProTacts
       "NOTE:#{VCard.escape(NoteSections.join(member, sections))}"
     end
 
-    # A property group to the `X-ABLabel` text anchoring it — the
+    # A property group to the `X-ABLabel` line 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"). Keyed
     # lowercased, a group name comparing without case as a property
-    # name does and as EditedContact#label_of pairs them.
-    #: () -> Hash[String, String]
-    def labels_by_group
-      properties
-        .filter_map { |property|
-          group = property.group
-          next if group.nil? || !property.name.casecmp?("X-ABLabel")
+    # name does and as EditedContact#label_of pairs them. A label with
+    # no text labels nothing, and is left out.
+    #: () -> Hash[String, VCard::Parser::Line]
+    def label_lines
+      vcard.lines
+        .filter_map { |line|
+          property = line.property
+          next if property.nil? || !property.name.casecmp?("X-ABLabel")
 
-          text = text_of(property)
-          next if text.nil?
+          group = property.group
+          next if group.nil? || text_of(property).nil?
 
-          [group.downcase, text] #: [String, String]
+          [group.downcase, line] #: [String, VCard::Parser::Line]
         }
         .to_h
     end
 
+    #: (VCard::Parser::Property property, Hash[String, VCard::Parser::Line] labels) -> VCard::Parser::Line?
+    def label_line_of(property, labels)
+      group = property.group
+      labels[group.downcase] if group
+    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 — and told which group lends the line, the provenance a
@@ -610,13 +641,14 @@ module ProTacts
     # as no address at all. Carries the line it was read from and its
     # reading, the other provenance-carrying shapes' own address for a
     # save.
-    #: (VCard::Parser::Property property, line: VCard::Parser::Line, lent_by: Group?) -> Address?
-    def address_of(property, line:, lent_by:)
+    #: (VCard::Parser::Property property, label_line: VCard::Parser::Line?, line: VCard::Parser::Line, lent_by: Group?) -> Address?
+    def address_of(property, label_line:, line:, lent_by:)
       components = components_of(property)
       return if components.none?
 
       po_box, extended, street, locality, region, postal_code, country = components
-      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, line:, property:, lent_by:)
+      Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, label_line:, line:,
+                  property:, lent_by:)
     end
 
   end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index c16405c..8b71b91 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -7,23 +7,24 @@ module ProTacts
   class Contact
     class Phone
       attr_reader value: String
-      attr_reader label: String?
+      attr_reader label_line: VCard::Parser::Line?
       attr_reader types: Array[String]
       attr_reader line: VCard::Parser::Line
       attr_reader property: VCard::Parser::Property
       attr_reader lent_by: Group?
 
-      def self.new: (value: String, label: String?, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
+      def self.new: (value: String, label_line: VCard::Parser::Line?, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
     end
 
     class Email
       attr_reader value: String
+      attr_reader label_line: VCard::Parser::Line?
       attr_reader types: Array[String]
       attr_reader line: VCard::Parser::Line
       attr_reader property: VCard::Parser::Property
       attr_reader lent_by: Group?
 
-      def self.new: (value: String, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
+      def self.new: (value: String, label_line: VCard::Parser::Line?, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
     end
 
     class Handle
@@ -68,6 +69,7 @@ module ProTacts
       attr_reader postal_code: String?
       attr_reader country: String?
       attr_reader types: Array[String]
+      attr_reader label_line: VCard::Parser::Line?
       attr_reader line: VCard::Parser::Line
       attr_reader property: VCard::Parser::Property
       attr_reader lent_by: Group?
@@ -81,6 +83,7 @@ module ProTacts
         postal_code: String?,
         country: String?,
         types: Array[String],
+        label_line: VCard::Parser::Line?,
         line: VCard::Parser::Line,
         property: VCard::Parser::Property,
         lent_by: Group?
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index edc0a06..7505cbf 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -2079,6 +2079,25 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # A labeled number is two lines, and blanking it removes both: the
+  # label left behind would name nothing, and the next line Contacts
+  # files under its group would wear it.
+  def test_a_blank_labeled_phone_removes_its_label_too
+    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", company: "0", etag: store.contact("ada").etag,
+                               phone: {digest => ""}
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      refute_includes card, "TEL"
+      refute_includes card, "X-ABLabel"
+    end
+  end
+
   ## Editing emails
 
   # The email rows: the phone row's own shape over EMAIL — one value