contacts edit: a row edits its label in the type column
Every repeatable row renders its label as a field in the type column,
with its kind's words in a datalist, and posts one shape:
<kind>[<digest>][label|<field>], and new_<kind>[<i>][...] for an
added one. Contact::Phone, Email, Address, and Handle each answer
#fields and #edit and write an added row with .add, so CardForm walks
every kind alike. A typed row writes a word Contacts models as TYPE
and any other as an X-ABLabel under the line's group or the card's
lowest free one (VCard#free_group). The handle's service moves back
into the type column, and .handle-row goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138CM3cNKnfJZ8LbDsCPRaf
change
commit f81ffc5e28ed4fd8e0793aadb434d3d0c7ccafb8
author Claude <noreply@anthropic.com>
date
parent 8e34856c
diff --git a/docs/plans/2026-10-22-a-row-edits-its-label.md b/docs/plans/2026-10-22-a-row-edits-its-label.md
index a520073..b9ba6a0 100644
--- a/docs/plans/2026-10-22-a-row-edits-its-label.md
+++ b/docs/plans/2026-10-22-a-row-edits-its-label.md
@@ -58,14 +58,16 @@ three things:
 - `#fields`, the row's values by form field name: `{"value" => ...}`
   for a phone, an email, and a handle, the six components for an
   address (`Contact::ADDRESS_COMPONENTS`).
-- `#lines(label, fields, card)`, the lines that replace the row's own,
-  none for a blank value. A class method of the same name writes an
-  added row. `card` is there for the one thing a label can need from
-  the rest of the card: a free property group.
-
-Phone, email, and address share their label rule through one module
-the three Data classes include, each naming its own vocabulary; the
-handle keeps `Contact.handle_line` as its rule. The address's value
+- `#edit(card, label, fields)`, the card with the row's own lines
+  rewritten — removed, for a blank value. A class method `.add` of the
+  same shape writes an added row. The row takes the card rather than
+  handing back lines because a labeled row is two lines, and only the
+  row knows which; a label can also need a free property group from
+  the rest of the card.
+
+Phone, email, and address share their label rule through functions on
+`Contact` (`edited_row`, `added_row`, `removed_row`), each naming its
+own vocabulary; the handle keeps `Contact.handle_line` as its rule. The address's value
 splice (`CardForm.address_line`) moves onto `Address` with it. This is
 the precedent `Contact.handle_line` already set: the line's spelling
 lives with the model that reads it.
@@ -137,8 +139,8 @@ handle's `service` field becomes `label`. `Web#card_form_params` becomes
 one loop over the kinds, each kind's field names from its row class,
 and `CardForm.edited_values`, `.edited_handles`, and `.edited_addresses`
 become one walk: for each row, skip it where label and fields both
-submit what it read, and substitute its lines (`#lines`) otherwise;
-then insert each added row's. The three walks differ today only in
+submit what it read, and hand it the card (`#edit`) otherwise; then
+add each added row. The three walks differ today only in
 what this plan moves onto the rows.
 
 ### The type column's field
@@ -198,11 +200,11 @@ One PR, in commits that each pass:
    labeled phone removes its label. The orphan fix stands alone.
 2. `#label` widens and emails and addresses read theirs; the record
    card shows them.
-3. The rows answer `#fields` and `#lines`; the form takes its one
-   shape and `CardForm` its one walk, with no visible change.
-4. The type column's field, and the handle row's second field goes.
-
-The hegel properties in `test/pro_tacts/admin/test_card_form.rb` get
-one per kind: a
-relabel reads back as the label typed, and a save that changes no
-field leaves the card's bytes alone.
+3. The rows answer `#fields` and `#edit`, the form takes its one
+   shape and `CardForm` its one walk, and the type column's field
+   replaces the handle row's second field. One commit: the form's new
+   shape and the field that posts it cannot pass apart.
+
+The hegel properties in `test/pro_tacts/admin/test_card_form.rb` grow
+two ways: a relabel reads back as the label typed, with no label left
+naming nothing, and the untouched save posts every kind's label too.
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index f027eaf..571a52f 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -14,6 +14,18 @@ module ProTacts
     #
     # Not a view, unlike its neighbours, so the Steepfile checks it.
     module CardForm
+      # The repeatable rows by the form's name for each kind: the field
+      # a standing row posts under and, prefixed `new_`, the one an
+      # added row does, each reading back into the row class that
+      # writes it (#edited_rows). Public for the route's shape checks
+      # (Web#card_form_params) and the editor's rows.
+      ROWS = {
+        "phone" => Contact::Phone,
+        "email" => Contact::Email,
+        "handle" => Contact::Handle,
+        "address" => Contact::Address,
+      }.freeze #: Hash[String, Contact::row_class]
+
       # A created contact's card: the envelope RFC 2426 section 4
       # requires — BEGIN, VERSION, and the N and FN that section makes
       # mandatory — plus the UID this server's id model is (see
@@ -69,10 +81,10 @@ module ProTacts
           end
         vcard = edited_text(vcard, "NICKNAME", params["nickname"], own.nickname)
         vcard = edited_text(vcard, "NOTE", params["note"], note_of(own))
-        vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
-        vcard = edited_values(own.emails, "email", "EMAIL", vcard, params)
-        vcard = edited_handles(own, vcard, params)
-        edited_addresses(own, vcard, params)
+        vcard = edited_rows(own.phones, "phone", vcard, params)
+        vcard = edited_rows(own.emails, "email", vcard, params)
+        vcard = edited_rows(own.handles, "handle", vcard, params)
+        edited_rows(own.addresses, "address", vcard, params)
       end
 
       # The group editor's save: the note replaced and the contact
@@ -84,7 +96,7 @@ module ProTacts
       def self.group_lines(group, params)
         reading = group.reading
         card = edited_text(reading.stored, "NOTE", params["note"], note_of(reading))
-        card = edited_addresses(reading, card, params)
+        card = edited_rows(reading.addresses, "address", card, params)
         card.lines.filter_map {
           line = it.content
           line unless line.empty?
@@ -147,121 +159,53 @@ module ProTacts
         vcard.replace(name, text_lines(name, value))
       end
 
-      # The single-value rows' half of the surgical save, walked once
-      # for the phones and once for the emails: each row names its line
-      # by digest, so a row the request leaves out is a line the save
-      # never mentions, and an unchanged row is skipped — the line keeps
-      # its own bytes by construction, not by careful re-rendering. A
-      # blank row removes its line; a changed row swaps the value under
-      # the line's own header (VCard.header_of), where the TYPE
-      # parameters no field models ride. The rows come from the
-      # contact, never the request, so a doctored digest names nothing
-      # and a missing one touches nothing.
+      # The repeatable rows' half of the surgical save, walked once per
+      # kind: each row names its line by digest, so a row the request
+      # leaves out is a line the save never mentions, and a row whose
+      # label and fields all submit what it read is skipped — the line
+      # keeps its own bytes by construction, not by careful
+      # re-rendering. Any other is the row's to rewrite (Contact::Phone#edit
+      # and kin), which is where the kinds differ: what a blank removes
+      # and where a label is written
+      # (docs/plans/2026-10-22-a-row-edits-its-label.md). A row posting
+      # no label leaves its label be, as the group editor's addresses
+      # do. The rows come from the contact, never the request, so a
+      # doctored digest names nothing and a missing one touches nothing.
       #
-      # `field` is the form's name for the kind and `name` the property
-      # an added row is written as. The rows the add dialog reveals
-      # (Admin::ContactsEdit) land as bare lines before END:VCARD, and a
-      # blank one inserts nothing — inserting absence is a no-op, the
-      # plan's rule for new rows, and the reason the rows nobody typed
-      # in cost nothing whether they were on screen or not.
-      #: (Array[Contact::Phone | Contact::Email] rows, String field, String name, VCard vcard, Hash[String, untyped] params) -> VCard
-      def self.edited_values(rows, field, name, vcard, params)
+      # `field` is the form's name for the kind. The rows the add
+      # dialog reveals (Admin::ContactsEdit) are named by their add
+      # index (`new_phone[i][value]`) rather than a bare [], because
+      # Rack refuses a key after an empty one — so they arrive as a
+      # hash of hashes and read out by their values, landing as bare
+      # lines before END:VCARD. A blank one inserts nothing.
+      #: (Array[Contact::Phone | Contact::Email | Contact::Handle | Contact::Address] rows, String field, VCard vcard, Hash[String, untyped] params) -> VCard
+      def self.edited_rows(rows, field, vcard, params)
+        kind = ROWS.fetch(field)
         edits = hash_field(params, field)
         rows.each do |row|
           submitted = edits[row.line.digest]
-          next if submitted.nil? || submitted.strip == row.value.strip
-
-          value = submitted.strip
-          vcard = value.empty? ? removed(vcard, row) : vcard.substitute(
-            row.line.digest, ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
-          )
-        end
-
-        none = [] #: Array[String]
-        submitted = params.fetch("new_#{field}", none) #: Array[String]
-        added = submitted.filter_map {
-          value = it.strip
-          "#{name}:#{VCard.escape(value)}" unless value.empty?
-        } #: Array[String]
-        vcard.insert(added)
-      end
-
-      # #edited_values' walk over a handle, a row of two fields: the
-      # service and the handle itself
-      # (docs/plans/2026-10-18-signal-handles.md, "The service is the
-      # row's"). A blank handle removes the line, a service alone being
-      # nothing to reach anyone at. A changed handle under an unchanged
-      # service swaps under the line's own header and scheme, the
-      # phones' rule; a changed service rewrites the header around the
-      # line's other parameters and moves the handle under `x-apple:`,
-      # the line's old scheme naming the old service's protocol. The
-      # rows the add dialog reveals are named by their add index, the
-      # address's reason (Rack refuses a key after a bare []).
-      #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
-      def self.edited_handles(contact, vcard, params)
-        rows = hash_field(params, "handle")
-        contact.handles.each do |handle|
-          submitted = rows[handle.line.digest]
           next if submitted.nil?
 
-          service = submitted["service"].to_s.strip
-          value = submitted["value"].to_s.strip
-          same_service = service == handle.service.to_s.strip
-          next if same_service && value == handle.value.strip
-
-          line =
-            if value.empty?
-              nil
-            elsif same_service
-              "#{VCard.header_of(handle.property)}#{handle.scheme}:#{VCard.escape(value)}"
-            else
-              Contact.handle_line(service, value, handle.property)
-            end
-          vcard = vcard.substitute(handle.line.digest, line ? [line] : [])
-        end
+          label = submitted["label"]&.strip
+          fields = submitted_fields(kind, submitted)
+          next if (label.nil? || label == row.label.to_s.strip) && fields.all? { |name, value| value == row.fields.fetch(name).strip }
 
-        added = hash_field(params, "new_handle").values #: Array[Hash[String, untyped]]
-        vcard.insert(added.filter_map { |row|
-          value = row["value"].to_s.strip
-          Contact.handle_line(row["service"].to_s.strip, value) unless value.empty?
-        })
-      end
-
-      # #edited_values' walk, over a row that is six fields rather than
-      # one (Admin::ContactsEdit). Removal is the reader's own rule
-      # (Contact#address_of): a row blank throughout, po box included,
-      # removes the line, where a partially blanked one keeps it —
-      # partial blanks are legal empty components.
-      #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
-      def self.edited_addresses(contact, vcard, params)
-        rows = hash_field(params, "address")
-        contact.addresses.each do |address|
-          submitted = rows[address.line.digest]
-          next if submitted.nil? || address_unchanged?(address, submitted)
-
-          line = address_line(address, submitted)
-          vcard = line ? vcard.substitute(address.line.digest, [line]) : removed(vcard, address)
+          vcard = row.edit(vcard, label, fields)
         end
 
-        # The add dialog names an added address by its add index
-        # (`new_address[i][street]`) rather than the bare [] every
-        # single-valued kind appends to, because Rack refuses a key
-        # after an empty one — so the rows arrive as a hash of hashes
-        # and read out by their values, several adds in one pass
-        # included.
-        added = hash_field(params, "new_address").values #: Array[Hash[String, untyped]]
-        vcard.insert(added.filter_map { address_line(nil, it) })
+        added = hash_field(params, "new_#{field}").values #: Array[Hash[String, untyped]]
+        added.each do |row|
+          vcard = kind.add(vcard, row["label"].to_s.strip, submitted_fields(kind, row))
+        end
+        vcard
       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
+      #: (Contact::row_class kind, Hash[String, untyped] submitted) -> Hash[String, String]
+      def self.submitted_fields(kind, submitted)
+        kind.field_names.to_h {
+          field = [it, submitted[it].to_s.strip] #: [String, String]
+          field
+        }
       end
 
       # A field the editors post as a hash keyed by row: none where the
@@ -277,45 +221,6 @@ module ProTacts
         value
       end
 
-      #: (Contact::Address address, Hash[String, untyped] submitted) -> bool
-      def self.address_unchanged?(address, submitted)
-        Contact::ADDRESS_COMPONENTS.all? { |name|
-          submitted[name].to_s.strip == address.public_send(name).to_s.strip
-        }
-      end
-
-      # One address row's replacement line: the raw components spliced
-      # and re-joined under the line's own header, or nil when the result
-      # is blank throughout (blank is absent, the reader's own rule for
-      # an ADR — the caller removes the line). A component that submits
-      # its current reading keeps its own bytes — the po box always,
-      # having no field, and any other the form left alone — where a
-      # moved one re-escapes from the form; the splice over
-      # still-escaped components is n_line's own rule,
-      # unescape-then-re-escape not being byte-stable. A nil address is
-      # an added row: seven fresh positions, po box empty, a bare ADR
-      # header.
-      #: (Contact::Address? address, Hash[String, untyped] submitted) -> String?
-      def self.address_line(address, submitted)
-        property = address&.property
-        if property.nil?
-          components = [""]
-        else
-          components = VCard.split_raw_components(property.value)
-        end
-        components << "" while components.length < 7
-        Contact::ADDRESS_COMPONENTS.each_with_index do |name, index|
-          position = index + 1
-          value = submitted[name].to_s.strip
-          current = address && address.public_send(name).to_s.strip
-          components[position] = value == current ? components.fetch(position) : VCard.escape(value)
-        end
-        return if components.all?(&:empty?)
-
-        header = property ? VCard.header_of(property) : "ADR:"
-        "#{header}#{components.join(";")}"
-      end
-
       # One component off the wire: nil for blank, the integer it names
       # otherwise. Digits only — a month select and ranged number inputs
       # are the browser's own constraint, and this is the server's.
@@ -434,7 +339,7 @@ module ProTacts
       # what the splice over the still-escaped value buys
       # (docs/plans/2026-09-05-web-card-editor.md). A component that
       # submits its current reading keeps its own bytes too, the rule
-      # address_line already writes under: unescape-then-re-escape is
+      # Contact.address_value already writes under: unescape-then-re-escape is
       # not byte-stable (VCard.split_raw_components), so a save that
       # moved a phone number must not rewrite the name it left alone. A
       # card with no N a form can read splices into a bare
@@ -463,8 +368,7 @@ module ProTacts
         "N:#{components.join(";")}"
       end
 
-      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,
+      private_class_method :edited_rows, :submitted_fields, :hash_field, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
                            :n_line, :company_name, :person_name, :edited_organization
     end
   end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 0aac558..9306cd0 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -40,11 +40,9 @@ module ProTacts
       # lists them — exactly the rows the save can insert
       # (docs/plans/2026-09-05-web-card-editor.md), because a type
       # listed here that the save cannot write would be a row that
-      # silently does nothing. Each name is the radio's value;
-      # `new_<type>[]` is the single-value kinds' field name and
-      # `new_address[<i>][<component>]` the structured one's, the
-      # bare [] unable to carry a component set (Rack refuses a key
-      # after an empty one). The birthday is the exception: its
+      # silently does nothing. Each name is the radio's value and
+      # `new_<type>[<i>][<field>]` its added row's fields
+      # (CardForm.edited_rows). The birthday is the exception: its
       # added row names the model's own `birthday[]` group, there
       # being no card line to insert and room for but one — see
       # #addable_types for when the dialog offers it.
@@ -71,16 +69,11 @@ module ProTacts
         ["country", "country"],
       ].freeze #: Array[[String, String]]
 
-      # A handle row's two fields, the service first because it says
-      # what the handle is (docs/plans/2026-10-18-signal-handles.md,
-      # "The service is the row's"), side by side on one line as a
-      # birthday's are (admin.css). Field name to placeholder, the
-      # address fields' shape.
-      HANDLE_FIELDS = [
-        ["service", "service"],
-        ["value", "handle"],
-      ].freeze #: Array[[String, String]]
-      private_constant :HANDLE_FIELDS
+      # The input type of each single-value kind's value field: the
+      # keyboard a phone number or an email address wants, on the one
+      # client that has one.
+      VALUE_TYPES = {"phone" => "tel", "email" => "email", "handle" => "text"}.freeze #: Hash[String, String]
+      private_constant :VALUE_TYPES
 
       # @rbs @contact: Contact
       # @rbs @login: String
@@ -249,17 +242,9 @@ module ProTacts
                     input(type: "text", name: "nickname", value: @own.nickname,
                           placeholder: @own.nickname ? "removed on save" : nil)
                   end
-                  @own.phones.each do |phone|
-                    value_row(phone.label || "phone",
-                              type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
-                  end
-                  @own.emails.each do |email|
-                    value_row(email.label || "email",
-                              type: "email", name: "email[#{email.line.digest}]", value: email.value)
-                  end
-                  @own.handles.each do
-                    handle_row(it)
-                  end
+                  @own.phones.each { value_row("phone", it) }
+                  @own.emails.each { value_row("email", it) }
+                  @own.handles.each { value_row("handle", it) }
                   @own.addresses.each do
                     address_row(it)
                   end
@@ -280,6 +265,7 @@ module ProTacts
                 end
               end
             add_property_dialog
+            label_words
           end
         end
       end
@@ -308,31 +294,35 @@ module ProTacts
       # Layout).
       #
       # The row is a div rather than the standing rows' label because
-      # one template serves every kind and the address kind holds six
-      # controls a label cannot name — so the single-value input
-      # carries its own aria-label, and the address inputs their
-      # placeholders.
+      # one template serves every kind, and each kind's row is the
+      # standing row's own shape: its label field in the type column
+      # and its value beside it, named by the add index rather than a
+      # digest — there is no line yet to digest, and Rack refuses a key
+      # after a bare [] (CardForm.edited_rows). Each `x-if` holds the
+      # one root Alpine allows it, so the two columns are two of them,
+      # every one a direct child of the row's grid.
       #
-      # `x-init` on the input rather than autofocus: it runs when the
+      # `x-init` on the value rather than autofocus: it runs when the
       # element is created, which is exactly when the row is added,
       # and there is nothing to focus on page load because `added`
-      # starts empty.
+      # starts empty. The value rather than the label, the value being
+      # what the row is added for.
       def added_rows
         template(x_for: "(kind, i) in added", ":key": "i") do
           div(class: "field") do
-            span("x-text": "kind")
-            # The single-value kinds: one input, its type and name
-            # bound to the kind — the keyboard a phone number or an
-            # email address wants, on the one client that has one.
-            template("x-if": "kind === 'phone' || kind === 'email'") do
-              input(":type": "kind === 'phone' ? 'tel' : kind", ":name": "`new_${kind}[]`",
+            template("x-if": "kind === 'birthday'") do
+              span("x-text": "kind")
+            end
+            template("x-if": "kind !== 'birthday'") do
+              input(type: "text", class: "row-label", autocomplete: "off",
+                    ":name": "`new_${kind}[${i}][label]`", ":list": "`${kind}-labels`",
+                    ":placeholder": "kind", ":aria-label": "`${kind} label`")
+            end
+            value_types = "{ #{VALUE_TYPES.map { |kind, type| "#{kind}: '#{type}'" }.join(", ")} }[kind]"
+            template("x-if": value_types) do
+              input(":type": value_types, ":name": "`new_${kind}[${i}][value]`",
                     ":aria-label": "kind", "x-init": "$el.focus()")
             end
-            # The address kind: the same six component fields a
-            # standing row gets, named by the add index rather than a
-            # digest — there is no line yet to digest, and the
-            # []-appended name the other kinds use cannot carry a
-            # component set (Rack refuses a key after a bare []).
             template("x-if": "kind === 'address'") do
               div(class: "field-stack") do
                 ADDRESS_FIELDS.each_with_index do |(component, label), index|
@@ -342,17 +332,6 @@ module ProTacts
                 end
               end
             end
-            # The handle kind: the standing row's two fields, named by
-            # the add index for the address's reason.
-            template("x-if": "kind === 'handle'") do
-              div(class: "handle-row") do
-                HANDLE_FIELDS.each_with_index do |(field, label), index|
-                  input(type: "text", ":name": "`new_handle[${i}][#{field}]`",
-                        placeholder: label, aria_label: label,
-                        "x-init": index.zero? ? "$el.focus()" : nil)
-                end
-              end
-            end
             # The birthday kind: the standing row's own three
             # controls with nothing prefilled, naming the model's
             # birthday[] group rather than a new_* name — no card
@@ -373,23 +352,52 @@ module ProTacts
         end
       end
 
-      # A standing single-value row — a phone, an email — editing its
-      # value and nothing else: the TYPE parameters ride in the line's
-      # own header, which the save keeps (VCard.header_of), where a
-      # header rebuilt from form fields would drop the parameters no
-      # field models, and macOS writes three TYPE parameters on one
-      # TEL. The digest in the field's name is the row's address, and
-      # a blank value removes the line — the state is unconditional,
-      # a row standing only over a line there is to lose. Identical
-      # duplicate lines share a digest and therefore a field name, and
-      # Rack keeps the last value of a duplicated name — editing one of
-      # a pair of byte-identical rows means blanking one, saving, then
-      # editing the other.
-      #: (String caption, type: String, name: String, value: String) -> void
-      def value_row(caption, type:, name:, value:)
-        label(class: "field", data: {blank_removes: true}) do
-          span { caption }
-          input(type:, name:, value:, placeholder: "removed on save")
+      # A row's label in the type column, where the record card shows it
+      # (docs/plans/2026-10-22-a-row-edits-its-label.md, "The type
+      # column's field"): a free word, its kind's usual ones offered
+      # beside it (#label_words), and the kind as its placeholder, so an
+      # unlabeled row reads as it always has. Blanking it is a relabel to
+      # nothing, never a removal, so admin.css's removal state asks after
+      # the value beside it and only strikes this. `autocomplete` off so
+      # the browser's memory of past entries does not crowd the list out.
+      #: (String field, String name, String? value) -> void
+      def label_field(field, name, value)
+        input(type: "text", class: "row-label", name:, value:, autocomplete: "off",
+              list: "#{field}-labels", placeholder: field, aria_label: "#{field} label")
+      end
+
+      # Each kind's suggested labels, one list per kind for every row of
+      # it, standing and added, to name by id.
+      #: () -> void
+      def label_words
+        CardForm::ROWS.each do |field, kind|
+          datalist(id: "#{field}-labels") do
+            kind.label_words.each { option(value: it) }
+          end
+        end
+      end
+
+      # A standing single-value row — a phone, an email, a handle —
+      # its label beside its value. Each field is the row's digest's,
+      # the address the save substitutes; the line's other parameters
+      # ride in its own header, which the save keeps (VCard.header_of),
+      # where a header rebuilt from form fields would drop the ones no
+      # field models — macOS writes three TYPE parameters on one TEL. A
+      # blank value removes the line, so the state is unconditional, a
+      # row standing only over a line there is to lose. A div rather
+      # than a label, as the address row is: it holds two controls, and
+      # each carries its own aria-label. Identical duplicate lines share
+      # a digest and therefore a field name, and Rack keeps the last
+      # value of a duplicated name — editing one of a pair of
+      # byte-identical rows means blanking one, saving, then editing the
+      # other.
+      #: (String field, Contact::Phone | Contact::Email | Contact::Handle row) -> void
+      def value_row(field, row)
+        name = "#{field}[#{row.line.digest}]"
+        div(class: "field", data: {blank_removes: true}) do
+          label_field(field, "#{name}[label]", row.label)
+          input(type: VALUE_TYPES.fetch(field), name: "#{name}[value]", value: row.value,
+                placeholder: "removed on save", aria_label: field)
         end
       end
 
@@ -511,24 +519,22 @@ module ProTacts
         end
       end
 
-      # An address row: the type's caption and a stack of component
+      # An address row: the label field and a stack of component
       # fields in the value column — the multi-line value's own lines
       # (docs/DESIGN.md: a multi-line value breaks inside the value
-      # column and stays in it). A div rather than the single-value
-      # rows' label, because the row holds six controls and a label
-      # names one — each input carries its placeholder as its
-      # aria-label instead, and the caption is the type's. The digest
-      # in each field's name is the row's address; the po box has no
-      # field, and the save preserves its bytes. The removal state
-      # rides only on a line with no po box: the save's removal is
-      # blank throughout, po box included (CardForm.address_line), so
+      # column and stays in it), each input carrying its placeholder as
+      # its aria-label. The digest in each field's name is the row's
+      # address; the po box has no field, and the save preserves its
+      # bytes. The removal state rides only on a line with no po box:
+      # the save's removal is blank throughout, po box included
+      # (Contact::Address#edit), so
       # a line surviving by its po box alone cannot be removed from
       # this form at all — its row wears no state however blank it
       # renders.
       #: (Contact::Address address) -> void
       def address_row(address)
         div(class: "field", data: {blank_removes: address.po_box.nil?}) do
-          span { address.label || "address" }
+          label_field("address", "address[#{address.line.digest}][label]", address.label)
           div(class: "field-stack") do
             ADDRESS_FIELDS.each do |component, label|
               input(type: "text", name: "address[#{address.line.digest}][#{component}]",
@@ -538,23 +544,6 @@ module ProTacts
         end
       end
 
-      # A standing handle row: the service and the handle, each a field
-      # of the row's digest, and a blank handle removes the line
-      # (CardForm.edited_handles), so the removal state is
-      # unconditional, the single-value rows' own.
-      #: (Contact::Handle handle) -> void
-      def handle_row(handle)
-        div(class: "field", data: {blank_removes: true}) do
-          span { "handle" }
-          div(class: "handle-row") do
-            HANDLE_FIELDS.each do |field, label|
-              input(type: "text", name: "handle[#{handle.line.digest}][#{field}]",
-                    value: handle.public_send(field), placeholder: label, aria_label: label)
-            end
-          end
-        end
-      end
-
       # Where a property a contact does not have yet gets named
       # (docs/DESIGN.md, "Adding is explicit"). It holds no value,
       # so there is nothing staged out of sight and Cancel is a plain
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 483a128..ae83a16 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -137,28 +137,271 @@ module ProTacts
       types.join(", ") unless types.empty?
     end
 
+    # The words Contacts models as a TYPE value, by the property they
+    # type, each to its spelling there: what a client has been seen
+    # writing (docs/apple-contacts.md, "The client rewrites every card
+    # it touches", and the fixtures), uppercase as Contacts writes
+    # them. Any other word is a custom label, the safe direction to be
+    # wrong in (docs/plans/2026-10-22-a-row-edits-its-label.md, "The
+    # typed kinds' rule"). A word joins when a recorded session shows
+    # Contacts spelling it as a type.
+    TYPE_WORDS = {
+      "TEL" => {"cell" => "CELL", "home" => "HOME", "work" => "WORK"},
+      "EMAIL" => {"home" => "HOME", "work" => "WORK"},
+      "ADR" => {"home" => "HOME", "work" => "WORK"},
+    }.freeze #: Hash[String, Hash[String, String]]
+
+    # Apple's own word for a row of no particular kind, written in its
+    # wrapped spelling because that is how the 31 emails labeled Other
+    # in the macOS data carry it (docs/plans/2026-09-18-phone-labels.md,
+    # "What the data has").
+    OTHER = "_$!<Other>!$_" #: String
+
+    # The words the editor offers in a typed row's label field: the
+    # type words and Apple's Other.
+    #: (String name) -> Array[String]
+    def self.typed_words(name) = [*TYPE_WORDS.fetch(name).keys, "other"]
+
+    # A typed row — a phone, an email, an address — rewritten into the
+    # card under `value`, its value already in the line's spelling.
+    # A label that is nil or says what the row read leaves the header
+    # alone, byte for byte; any other takes off what the reader showed
+    # and writes the new word (#labeled_lines).
+    #: (VCard card, Phone | Email | Address row, String value, String? label) -> VCard
+    def self.edited_row(card, row, value, label)
+      if label.nil? || label == row.label.to_s.strip
+        return card.substitute(row.line.digest, ["#{VCard.header_of(row.property)}#{value}"])
+      end
+
+      label_line = row.label_line
+      card = card.substitute(label_line.digest, []) if label_line
+      card.substitute(row.line.digest, labeled_lines(card, row.property, row.types, value, label))
+    end
+
+    # An added typed row's lines into the card, a bare line of `name`
+    # under its label.
+    #: (VCard card, String name, String value, String? label) -> VCard
+    def self.added_row(card, name, value, label)
+      property = VCard::Parser::Property.new(group: nil, name:, parameters: [], value: "")
+      card.insert(labeled_lines(card, property, [], value, label.to_s))
+    end
+
+    # A phone's or an email's edit, one text value: a blank removes
+    # the row, a changed one re-escapes from the form, and an unchanged
+    # one under a new label keeps its bytes.
+    #: (VCard card, Phone | Email row, String? label, Hash[String, String] fields) -> VCard
+    def self.edited_value(card, row, label, fields)
+      submitted = fields.fetch("value")
+      return removed_row(card, row) if submitted.empty?
+
+      edited_row(card, row, submitted == row.value.strip ? row.property.value : VCard.escape(submitted), label)
+    end
+
+    # An added phone or email, nothing for a blank one: inserting
+    # absence is a no-op (docs/plans/2026-09-05-web-card-editor.md).
+    #: (VCard card, String name, String? label, Hash[String, String] fields) -> VCard
+    def self.added_value(card, name, label, fields)
+      value = fields.fetch("value")
+      value.empty? ? card : added_row(card, name, VCard.escape(value), label)
+    end
+
+    # A typed 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 card, Phone | Email | Address row) -> VCard
+    def self.removed_row(card, row)
+      card = card.substitute(row.line.digest, [])
+      label_line = row.label_line
+      label_line ? card.substitute(label_line.digest, []) : card
+    end
+
+    # The line of `property` under `value` and the label `label`, and
+    # the X-ABLabel beside it where the label needs one. The types the
+    # reader showed (`shown`) come off and every other parameter stays
+    # — `pref`, the defaults the reader hides — so a relabel removes
+    # exactly what the field rendered. A label every comma-separated
+    # word of which is a type word goes in as TYPE values; any other
+    # goes in an X-ABLabel under the line's group, or the card's
+    # lowest free one (VCard#free_group). Blank writes neither.
+    #: (VCard card, VCard::Parser::Property property, Array[String] shown, String value, String label) -> Array[String]
+    def self.labeled_lines(card, property, shown, value, label)
+      kept = property.parameters.reject { |name, type| name.casecmp?("TYPE") && shown.include?(type.downcase) }
+      words = label.split(",").map(&:strip).reject(&:empty?)
+      none = {} #: Hash[String, String]
+      types = TYPE_WORDS.fetch(property.name.upcase, none)
+      if words.all? { types.key?(it.downcase) }
+        typed = words.map { ["TYPE", types.fetch(it.downcase)] } #: Array[[String, String]]
+        line = VCard::Parser::Property.new(group: property.group, name: property.name, parameters: [*kept, *typed], value:)
+        return ["#{VCard.header_of(line)}#{value}"]
+      end
+
+      group = property.group || card.free_group
+      text = label.casecmp?("other") ? OTHER : VCard.escape(label)
+      line = VCard::Parser::Property.new(group:, name: property.name, parameters: kept, value:)
+      ["#{VCard.header_of(line)}#{value}", "#{group}.X-ABLabel:#{text}"]
+    end
+
+    # An address's value, components spliced from the form's fields:
+    # the raw components re-joined, or nil when the result is blank
+    # throughout (blank is absent, the reader's own rule for an ADR —
+    # the caller removes the line). A component that submits its
+    # current reading keeps its own bytes — the po box always, having
+    # no field, and any other the form left alone — where a moved one
+    # re-escapes from the form; the splice over still-escaped
+    # components is n_line's own rule (Admin::CardForm),
+    # unescape-then-re-escape not being byte-stable. A nil address is
+    # an added row: seven fresh positions, po box empty.
+    #: (Address? address, Hash[String, String] fields) -> String?
+    def self.address_value(address, fields)
+      components = address ? VCard.split_raw_components(address.property.value) : [""]
+      components << "" while components.length < 7
+      ADDRESS_COMPONENTS.each_with_index do |name, index|
+        position = index + 1
+        value = fields.fetch(name)
+        current = address && address.public_send(name).to_s.strip
+        components[position] = value == current ? components.fetch(position) : VCard.escape(value)
+      end
+      components.join(";") unless components.all?(&:empty?)
+    end
+
+    private_class_method :labeled_lines
+
     # Reopened rather than defined in the blocks above, for the reason
-    # VCard::Parser::Property is.
+    # VCard::Parser::Property is. Each row answers the editor the same
+    # three things — its label, its fields by form name, and itself
+    # rewritten into a card — and each class writes an added one, so
+    # the save walks every kind alike and the difference between
+    # them is where each writes its label
+    # (docs/plans/2026-10-22-a-row-edits-its-label.md, "The row writes
+    # its own label").
     class Phone
+      #: () -> Array[String]
+      def self.label_words = Contact.typed_words("TEL")
+
+      #: () -> Array[String]
+      def self.field_names = %w[value]
+
       #: () -> String?
       def label = Contact.label_of(label_line, types)
+
+      #: () -> Hash[String, String]
+      def fields = {"value" => value}
+
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def edit(card, label, fields)
+        Contact.edited_value(card, self, label, fields)
+      end
+
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def self.add(card, label, fields)
+        Contact.added_value(card, "TEL", label, fields)
+      end
     end
 
     class Email
+      #: () -> Array[String]
+      def self.label_words = Contact.typed_words("EMAIL")
+
+      #: () -> Array[String]
+      def self.field_names = %w[value]
+
       #: () -> String?
       def label = Contact.label_of(label_line, types)
+
+      #: () -> Hash[String, String]
+      def fields = {"value" => value}
+
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def edit(card, label, fields)
+        Contact.edited_value(card, self, label, fields)
+      end
+
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def self.add(card, label, fields)
+        Contact.added_value(card, "EMAIL", label, fields)
+      end
     end
 
     class Address
+      #: () -> Array[String]
+      def self.label_words = Contact.typed_words("ADR")
+
+      #: () -> Array[String]
+      def self.field_names = ADDRESS_COMPONENTS
+
       #: () -> String?
       def label = Contact.label_of(label_line, types)
+
+      #: () -> Hash[String, String]
+      def fields
+        ADDRESS_COMPONENTS.to_h {
+          field = [it, public_send(it).to_s] #: [String, String]
+          field
+        }
+      end
+
+      # Removal is the reader's own rule (Contact#address_of): a row
+      # blank throughout, po box included, removes the line, where a
+      # partially blanked one keeps it — partial blanks are legal empty
+      # components.
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def edit(card, label, fields)
+        value = Contact.address_value(self, fields)
+        value ? Contact.edited_row(card, self, value, label) : Contact.removed_row(card, self)
+      end
+
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def self.add(card, label, fields)
+        value = Contact.address_value(nil, fields)
+        value ? Contact.added_row(card, "ADR", value, label) : card
+      end
     end
 
     class Handle
+      # Signal, the one service this server writes
+      # (docs/plans/2026-10-18-signal-handles.md); any other is typed.
+      #: () -> Array[String]
+      def self.label_words = %w[signal]
+
+      #: () -> Array[String]
+      def self.field_names = %w[value]
+
       # A handle's word is its service, the column the record card has
       # always shown it in.
       #: () -> String?
       def label = service
+
+      #: () -> Hash[String, String]
+      def fields = {"value" => value}
+
+      # A blank handle removes the line, a service alone being nothing
+      # to reach anyone at. A changed handle under an unchanged service
+      # swaps under the line's own header and scheme, the phones' rule;
+      # a changed service rewrites the header around the line's other
+      # parameters and moves the handle under `x-apple:`, the line's old
+      # scheme naming the old service's protocol
+      # (docs/plans/2026-10-18-signal-handles.md, "The service is the
+      # row's").
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def edit(card, label, fields)
+        submitted = fields.fetch("value")
+        return card.substitute(line.digest, []) if submitted.empty?
+
+        written =
+          if label.nil? || label == service.to_s.strip
+            "#{VCard.header_of(property)}#{scheme}:#{VCard.escape(submitted)}"
+          else
+            Contact.handle_line(label, submitted, property)
+          end
+        card.substitute(line.digest, [written])
+      end
+
+      #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+      def self.add(card, label, fields)
+        value = fields.fetch("value")
+        value.empty? ? card : card.insert([Contact.handle_line(label.to_s, value)])
+      end
     end
 
     # A handle's line in Contacts.app's spelling, the service in
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index b65479a..c18edb2 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -165,6 +165,19 @@ module ProTacts
       @properties = lines.filter_map { it.property }
     end
 
+    # The lowest `itemN` property group no line of the card names, the
+    # spelling Contacts gives its own (docs/apple-contacts.md, "An
+    # annotation survives only on its own line"), for a line that
+    # needs a group to carry a label beside it. A group name compares
+    # without case, as a property name does.
+    #: () -> String
+    def free_group
+      taken = properties.filter_map { it.group&.downcase }
+      number = 1
+      number += 1 while taken.include?("item#{number}")
+      "item#{number}"
+    end
+
     # Whether the parsed properties carry the envelope RFC 2426 section
     # 4 requires of a card: BEGIN:VCARD first, END:VCARD last, and a
     # VERSION in between. Deciding that is deliberately not the
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d362581..df0031d 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -175,10 +175,11 @@ module ProTacts
     end
 
     # The params Admin::CardForm reads, held to the shapes the contact
-    # and group editors post: each text field a string, each phone and
-    # email field a hash of strings keyed by its row's digest, each
-    # added phone or email a list of strings, and every address row,
-    # added address and the birthday row a hash of component strings.
+    # and group editors post: each text field a string, every
+    # repeatable row — standing, keyed by its digest, or added, by its
+    # add index — a hash of its label and its kind's field strings
+    # (CardForm::ROWS), and the birthday row a hash of component
+    # strings.
     # Any other shape is a POST made by hand, refused here as a 400
     # (the error handler) rather than read around in CardForm. An
     # absent field is a form that rendered no such rows — or a name
@@ -186,18 +187,12 @@ module ProTacts
     #: () -> Hash[String, untyped]
     def card_form_params
       typecast_params.str(%w[first middle last organization nickname note])
-      typecast_params.array(:str, %w[new_phone new_email])
-      %w[phone email].each do |key|
-        rows = typecast_params.Hash(key)
-        rows&.each_key { typecast_params[key].str!(it) }
-      end
-      %w[address new_address].each do |key|
-        rows = typecast_params.Hash(key)
-        rows&.each_key { typecast_params[key][it].str(Contact::ADDRESS_COMPONENTS) }
-      end
-      %w[handle new_handle].each do |key|
-        rows = typecast_params.Hash(key)
-        rows&.each_key { typecast_params[key][it].str(%w[service value]) }
+      Admin::CardForm::ROWS.each do |field, kind|
+        fields = ["label", *kind.field_names]
+        [field, "new_#{field}"].each do |key|
+          rows = typecast_params.Hash(key)
+          rows&.each_key { typecast_params[key][it].str(fields) }
+        end
       end
       typecast_params["birthday"].str(%w[year month day]) if typecast_params.Hash("birthday")
       request.params
diff --git a/public/admin.css b/public/admin.css
index 281be61..00ef738 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -250,36 +250,20 @@
   color: var(--gl-color-danger);
 }
 
-/* A handle row's two fields — service and handle — on one line in
-   the value column: the service names what the handle is, so the two
-   read as one value, the birthday row's reading. The service is the
-   shorter word and takes the narrower share. On a phone the value
-   column held each field to a stub that clipped its text, so below
-   the date row's own threshold (27rem of row, asked of the row for
-   that rule's reason, below) the pair stacks, the service over the
-   handle, each the column's full width. */
-.card-body form.field-stack .field > .handle-row {
-  display: flex;
-  gap: var(--gl-space-2xs);
-}
-.card-body form.field-stack .field > .handle-row > input {
-  flex: 1 1 0;
-  min-width: 0;
+/* A row's label field (Admin::ContactsEdit#label_field) sits in the
+   type column the caption held and the record card's key still holds,
+   so it reads in the caption's register — Gloss's .field type, mono
+   uppercase at the step below the value — inside the box that says it
+   can be edited (docs/plans/2026-10-22-a-row-edits-its-label.md). The
+   word is stored as typed; only its showing is uppercase. */
+.card-body form.field-stack > .field > .row-label {
   padding-block: var(--gl-space-3xs);
-}
-.card-body form.field-stack .field > .handle-row > input:last-child {
-  flex-grow: 2;
-}
-.card-body form.field-stack > .field:has(> .handle-row) {
-  container-type: inline-size;
-}
-@container (width < 27rem) {
-  .card-body form.field-stack .field > .handle-row {
-    flex-direction: column;
-  }
-  .card-body form.field-stack .field > .handle-row > input {
-    flex: none;
-  }
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--2);
+  font-weight: 500;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--gl-color-text-secondary);
 }
 
 /* The date line breaks in two when its row cannot hold a whole
@@ -401,17 +385,25 @@
    refused one. One gap, noted rather than scripted: a
    whitespace-only value hides the placeholder and the strike with
    it, while the save strips before it compares. */
-.field[data-blank-removes]:has(> input:placeholder-shown) > span,
+.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > :is(span, .row-label),
 .field[data-blank-removes]:has(> textarea:placeholder-shown) > span,
-.field[data-blank-removes]:has(> .field-stack):not(:has(input:not(:placeholder-shown))) > span,
+.field[data-blank-removes]:has(> .field-stack):not(:has(> .field-stack > input:not(:placeholder-shown))) > :is(span, .row-label),
 .field[data-blank-removes]:has(> .date-row):has(option[value=""]:checked):not(:has(input[type="number"]:not(:placeholder-shown))) > span {
   text-decoration: line-through;
   color: var(--gl-color-danger);
 }
-.field[data-blank-removes]:has(> input:placeholder-shown) > input::placeholder,
+.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > input:not(.row-label)::placeholder,
 .field[data-blank-removes]:has(> textarea:placeholder-shown) > textarea::placeholder {
   color: var(--gl-color-danger);
 }
+/* A label field's own placeholder is the kind it stands in for, and
+   is struck with the row. A pseudo-element answers no :is() above, so
+   it takes its own rule. */
+.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > .row-label::placeholder,
+.field[data-blank-removes]:has(> .field-stack):not(:has(> .field-stack > input:not(:placeholder-shown))) > .row-label::placeholder {
+  text-decoration: line-through;
+  color: var(--gl-color-danger);
+}
 
 /* Gloss's field contract styles input and select but not textarea
    (base.css only resets it), and the editor's note field is one —
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 8b71b91..f55e8fd 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -5,6 +5,11 @@
 # carries its signature inline.
 module ProTacts
   class Contact
+    # The repeatable rows' classes, which the editor walks alike
+    # (Admin::CardForm.edited_rows). An alias the inline syntax has no
+    # spelling for.
+    type row_class = singleton(Phone) | singleton(Email) | singleton(Handle) | singleton(Address)
+
     class Phone
       attr_reader value: String
       attr_reader label_line: VCard::Parser::Line?
diff --git a/test/pro_tacts/admin/test_card_form.rb b/test/pro_tacts/admin/test_card_form.rb
index 01c7bf8..a8426da 100644
--- a/test/pro_tacts/admin/test_card_form.rb
+++ b/test/pro_tacts/admin/test_card_form.rb
@@ -44,17 +44,20 @@ class CardFormTest < Minitest::Test
       "first" => given.to_s, "middle" => additional.to_s, "last" => family.to_s,
       "nickname" => own.nickname.to_s,
       "note" => ProTacts::Admin::CardForm.note_of(own).to_s.gsub("\n", "\r\n"),
-      "phone" => own.phones.to_h { [it.line.digest, it.value] },
-      "email" => own.emails.to_h { [it.line.digest, it.value] },
-      "address" => own.addresses.to_h { |address|
-        [address.line.digest, ProTacts::Contact::ADDRESS_COMPONENTS.to_h { [it, address.public_send(it).to_s] }]
-      },
+      "phone" => rows(own.phones),
+      "email" => rows(own.emails),
+      "handle" => rows(own.handles),
+      "address" => rows(own.addresses),
     }
     organization = ProTacts::Admin::CardForm.organization_of(contact.stored).to_s
     params["organization"] = organization unless organization.empty?
     params
   end
 
+  # Each row's fields as it renders them: its label, empty for none,
+  # and its fields.
+  def rows(rows) = rows.to_h { [it.line.digest, {"label" => it.label.to_s, **it.fields}] }
+
   # A save that changed nothing changes no byte, however the card spells
   # what the form shows: parameters and groups the form never modeled,
   # folds, LF terminators, escapes the writer spells another way,
@@ -93,6 +96,42 @@ class CardFormTest < Minitest::Test
     end
   end
 
+  ## Labels
+
+  # Whatever a row is relabelled to reads back as that word, whichever
+  # spelling the save picked for it — a type, an X-ABLabel, Apple's
+  # wrapped Other — over lines grouped, labeled, and typed every way a
+  # client writes them, the other rows untouched beside it
+  # (docs/plans/2026-10-22-a-row-edits-its-label.md). A type word reads
+  # in the reader's lowercase, and blank reads as no label.
+  def test_a_relabelled_row_reads_back_as_its_label
+    Hegel.test(test_cases: 500) do |tc|
+      lines = Array.new(tc.draw(integers(min_value: 1, max_value: 3))) { |index|
+        group = tc.draw(optional(sampled_from(["item#{index + 1}", "ITEM#{index + 7}"])))
+        header = tc.draw(sampled_from(["TEL", "TEL;TYPE=cell", "TEL;type=HOME;type=VOICE;type=pref", "EMAIL;TYPE=INTERNET"]))
+        label = group && tc.draw(optional(sampled_from(["Google Voice", "_$!<Other>!$_"])))
+        ["#{group && "#{group}."}#{header}:+1-555-010#{index}\r\n", *(label ? ["#{group}.X-ABLabel:#{label}\r\n"] : [])]
+      }.flatten
+      stored = ["BEGIN:VCARD\r\n", "VERSION:3.0\r\n", "N:Lovelace;Ada;;;\r\n", "FN:Ada Lovelace\r\n", *lines, "UID:aiden\r\n", "END:VCARD\r\n"].join
+      contact = ProTacts::Contact.new(id: "aiden", stored: ProTacts::VCard.new(stored), birthday: nil, inherited: [])
+      field, rows = tc.draw(sampled_from([["phone", :phones], ["email", :emails]].select { contact.public_send(it[1]).any? }))
+      row = tc.draw(sampled_from(contact.public_send(rows)))
+      label = tc.draw(sampled_from(["", "home", "Work", "cell", "other", "Google Voice", "home, work", "lab, too"]))
+      params = untouched(contact)
+      params[field] = params.fetch(field).merge(row.line.digest => {"label" => label, "value" => row.value})
+
+      saved = ProTacts::Admin::CardForm.contact_card(contact, false, "Ada", "", "Lovelace", params)
+      after = ProTacts::Contact.new(id: "aiden", stored: saved, birthday: nil, inherited: [])
+      relabelled = after.public_send(rows).find { it.value == row.value }
+
+      expected = {"" => nil, "Work" => "work", "other" => "Other"}.fetch(label, label)
+      expected.nil? ? assert_nil(relabelled.label) : assert_equal(expected, relabelled.label)
+      assert_equal contact.public_send(rows).length, after.public_send(rows).length
+      # No label is left naming nothing, and none is lost.
+      assert_equal [*after.phones, *after.emails].count(&:label_line), saved.properties.count { it.name.casecmp?("X-ABLabel") }
+    end
+  end
+
   ## The birthday row
 
   # Whatever the birthday row saves in a shape it serves — a month and
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7df8edd..063bcdf 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1805,7 +1805,7 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # A po box has no field, and the save's removal is blank throughout,
-  # po box included (Admin::CardForm.address_line) — so a line
+  # po box included (Contact::Address#edit) — so a line
   # surviving by its po box alone cannot be removed from this form, and
   # its row, blank as it renders, wears no removal state.
   def test_a_po_box_only_address_row_wears_no_removal_state
@@ -1814,11 +1814,12 @@ class AdminContactsPagesTest < Minitest::Test
       "ADR;TYPE=home:PO Box 1;;;;;",
     )
 
-    with_contacts({"ada" => po}) do
+    with_contacts({"ada" => po}) do |store|
       get "/contacts/ada/edit"
 
+      digest = store.contact("ada").addresses.first.line.digest
       assert_includes last_response.body,
-        '<div class="field"><span>home</span><div class="field-stack">'
+        '<div class="field"><input type="text" class="row-label" name="address[' + digest + '][label]" value="home"'
     end
   end
 
@@ -1875,7 +1876,7 @@ class AdminContactsPagesTest < Minitest::Test
       digest = store.contact("ada").phones.first.line.digest
       post "/contacts/ada", first: "Ada", middle: "B.", last: "Lovelace",
                                note: "Countess of Lovelace.",
-                               company: "0", etag: store.contact("ada").etag, phone: {digest => "+1-555-0199"}
+                               company: "0", etag: store.contact("ada").etag, phone: {digest => {"value" => "+1-555-0199"}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -1984,7 +1985,7 @@ class AdminContactsPagesTest < Minitest::Test
   # component's own bytes, rather than rounding through unescape and
   # re-escape — which is not byte-stable: `\x` is no escape the
   # reader knows, so it survives the read and the writer would
-  # double its backslash. The rule address_line already writes under
+  # double its backslash. The rule Contact.address_value already writes under
   # (CardForm.n_line), at the name's grain.
   def test_a_name_component_returned_unchanged_keeps_its_own_bytes
     odd = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron\\x;;")
@@ -2046,10 +2047,11 @@ class AdminContactsPagesTest < Minitest::Test
 
   ## Editing phones
 
-  # The phone rows: one value field per line, named by the line's
-  # digest — the address the save substitutes — beside the type's own
-  # caption. The blank row a new phone goes in is in the add dialog,
-  # not the card (see below).
+  # The phone rows: a label field in the type column and a value field
+  # beside it, both named by the line's digest — the address the save
+  # substitutes. The label offers the kind's words from a list by id,
+  # and wears the kind as its placeholder. The blank row a new phone
+  # goes in is in the add dialog, not the card (see below).
   def test_the_edit_screen_renders_a_digest_named_field_per_phone
     with_contacts({"ada" => ADA}) do |store|
       get "/contacts/ada/edit"
@@ -2057,14 +2059,19 @@ class AdminContactsPagesTest < Minitest::Test
       digest = store.contact("ada").phones.first.line.digest
       body = last_response.body
       assert_includes body,
-        '<label class="field" data-blank-removes><span>mobile</span>' \
-        '<input type="tel" name="phone[' + digest + ']" value="+1-555-0100" placeholder="removed on save"></label>'
+        '<div class="field" data-blank-removes>' \
+        '<input type="text" class="row-label" name="phone[' + digest + '][label]" value="mobile" autocomplete="off" ' \
+        'list="phone-labels" placeholder="phone" aria-label="phone label">' \
+        '<input type="tel" name="phone[' + digest + '][value]" value="+1-555-0100" placeholder="removed on save" ' \
+        'aria-label="phone"></div>'
+      assert_includes body,
+        '<datalist id="phone-labels"><option value="cell"></option><option value="home"></option>' \
+        '<option value="work"></option><option value="other"></option></datalist>'
     end
   end
 
-  # The edit screen's phone row captions render a label the same way
-  # the show screen's keys do — both read Contact::Phone#label — while
-  # the field keeps editing the number alone.
+  # The edit screen's phone row labels render the same word the show
+  # screen's keys do — both read Contact::Phone#label.
   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")
@@ -2074,8 +2081,7 @@ class AdminContactsPagesTest < Minitest::Test
 
       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>'
+        '<input type="text" class="row-label" name="phone[' + digest + '][label]" value="Google Voice"'
     end
   end
 
@@ -2089,7 +2095,7 @@ class AdminContactsPagesTest < Minitest::Test
     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 => ""}
+                               phone: {digest => {"value" => ""}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -2098,19 +2104,117 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  ## Editing labels
+
+  # A relabel posts the row's label and value both, the value as it
+  # rendered: the save writes the new word and leaves the value's bytes.
+  def relabel(store, field, label)
+    contact = store.contact("ada")
+    row = contact.own.public_send({"phone" => :phones, "email" => :emails, "address" => :addresses}.fetch(field)).first
+    post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: contact.etag,
+                             field => {row.line.digest => {"label" => label, **row.fields}}
+    assert_equal 303, last_response.status
+    store.contact("ada").vcard.to_s
+  end
+
+  # A word Contacts models goes in as a TYPE, in its spelling, in place
+  # of the types the label field showed
+  # (docs/plans/2026-10-22-a-row-edits-its-label.md, "The typed kinds'
+  # rule").
+  def test_a_relabel_to_a_type_word_writes_the_type
+    with_contacts({"ada" => ADA}) do |store|
+      card = relabel(store, "phone", "Work")
+
+      assert_includes card, "TEL;TYPE=WORK:+1-555-0100\r\n"
+    end
+  end
+
+  # What the label never showed stays: `pref` and the default `voice`
+  # ride through a relabel, with the line's other parameters.
+  def test_a_relabel_keeps_the_types_the_label_did_not_show
+    apple = ADA.sub("TEL;TYPE=mobile:", "TEL;type=CELL;type=VOICE;type=pref:")
+
+    with_contacts({"ada" => apple}) do |store|
+      card = relabel(store, "phone", "home")
+
+      assert_includes card, "TEL;type=VOICE;type=pref;TYPE=HOME:+1-555-0100\r\n"
+    end
+  end
+
+  # Any other word is a custom label: an X-ABLabel under the card's
+  # lowest free group, straight after its row — the pair Contacts keeps
+  # bound (docs/apple-contacts.md, "An annotation survives only on its
+  # own line").
+  def test_a_relabel_to_any_other_word_writes_an_x_ablabel_in_a_free_group
+    taken = ADA.sub("EMAIL;", "item1.EMAIL;")
+
+    with_contacts({"ada" => taken}) do |store|
+      card = relabel(store, "phone", "Google Voice")
+
+      assert_includes card, "item2.TEL:+1-555-0100\r\nitem2.X-ABLabel:Google Voice\r\n"
+      assert_equal "Google Voice", store.contact("ada").phones.first.label
+    end
+  end
+
+  # A labeled row keeps its group: relabelled, the old label goes and
+  # the new one takes its place; relabelled to a type word, the label
+  # goes and the type replaces the types it hid behind.
+  def test_a_relabel_of_a_labeled_row_replaces_its_label
+    labeled = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n",
+      "item1.TEL;type=CELL:+1-555-0100\r\nURL:https://example.com\r\nitem1.X-ABLabel:Google Voice\r\n")
+
+    with_contacts({"ada" => labeled}) do |store|
+      card = relabel(store, "phone", "Signal number")
+      assert_includes card, "item1.TEL:+1-555-0100\r\nitem1.X-ABLabel:Signal number\r\nURL:https://example.com\r\n"
+      refute_includes card, "Google Voice"
+
+      card = relabel(store, "phone", "home")
+      assert_includes card, "item1.TEL;TYPE=HOME:+1-555-0100\r\nURL:https://example.com\r\n"
+      refute_includes card, "X-ABLabel"
+    end
+  end
+
+  # Other is Apple's own word and goes in wrapped, as the macOS data
+  # carries it; a blank label writes no label at all.
+  def test_other_writes_apples_wrapped_label_and_blank_writes_none
+    with_contacts({"ada" => ADA}) do |store|
+      card = relabel(store, "email", "other")
+      assert_includes card, "item1.EMAIL:ada@example.com\r\nitem1.X-ABLabel:_$!<Other>!$_\r\n"
+      assert_equal "Other", store.contact("ada").emails.first.label
+
+      card = relabel(store, "address", "")
+      assert_includes card, "ADR:;;12 Analytical Way;"
+    end
+  end
+
+  # An added row takes its label as a standing one does.
+  def test_an_added_row_lands_under_its_label
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
+                               new_email: {"0" => {"label" => "work", "value" => "countess@example.com"}},
+                               new_address: {"0" => {"label" => "lab", "street" => "1 Engine Row"}}
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "EMAIL;TYPE=WORK:countess@example.com\r\n"
+      assert_includes card, "item1.ADR:;;1 Engine Row;;;;\r\nitem1.X-ABLabel:lab\r\n"
+    end
+  end
+
   ## Editing emails
 
-  # The email rows: the phone row's own shape over EMAIL — one value
-  # field per line, named by the line's digest, beside the type's own
-  # caption.
+  # The email rows: the phone row's own shape over EMAIL.
   def test_the_edit_screen_renders_a_digest_named_field_per_email
     with_contacts({"ada" => ADA}) do |store|
       get "/contacts/ada/edit"
 
       digest = store.contact("ada").emails.first.line.digest
       assert_includes last_response.body,
-        '<label class="field" data-blank-removes><span>home</span>' \
-        '<input type="email" name="email[' + digest + ']" value="ada@example.com" placeholder="removed on save"></label>'
+        '<div class="field" data-blank-removes>' \
+        '<input type="text" class="row-label" name="email[' + digest + '][label]" value="home" autocomplete="off" ' \
+        'list="email-labels" placeholder="email" aria-label="email label">' \
+        '<input type="email" name="email[' + digest + '][value]" value="ada@example.com" placeholder="removed on save" ' \
+        'aria-label="email"></div>'
     end
   end
 
@@ -2125,7 +2229,7 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => two}) do |store|
       digest = store.contact("ada").emails.first.line.digest
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               email: {digest => "lovelace@example.com"}
+                               email: {digest => {"value" => "lovelace@example.com"}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -2143,7 +2247,7 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => apple}) do |store|
       digest = store.contact("ada").emails.first.line.digest
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               email: {digest => "lovelace@example.com"}
+                               email: {digest => {"value" => "lovelace@example.com"}}
 
       assert_equal 303, last_response.status
       assert_includes store.contact("ada").vcard.to_s,
@@ -2157,7 +2261,7 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => ADA}) do |store|
       digest = store.contact("ada").emails.first.line.digest
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               email: {digest => " "}
+                               email: {digest => {"value" => " "}}
 
       assert_equal 303, last_response.status
       refute_includes store.contact("ada").vcard.to_s, "EMAIL"
@@ -2172,8 +2276,8 @@ class AdminContactsPagesTest < Minitest::Test
       email_digest = store.contact("ada").emails.first.line.digest
       address_digest = store.contact("ada").addresses.first.line.digest
       post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
-                               company: "0", etag: store.contact("ada").etag, new_email: [""],
-                               email: {email_digest => "ada@example.com"},
+                               company: "0", etag: store.contact("ada").etag, new_email: {"0" => {"value" => ""}},
+                               email: {email_digest => {"value" => "ada@example.com"}},
                                address: {address_digest => {
                                  "street" => "12 Analytical Way", "locality" => "London",
                                  "region" => "England", "postal_code" => "NW1 1AA",
@@ -2190,7 +2294,8 @@ class AdminContactsPagesTest < Minitest::Test
   def test_an_added_email_lands_as_a_bare_email_before_the_end
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               new_email: ["lovelace@example.com", "", "countess@example.com"]
+                               new_email: {"0" => {"value" => "lovelace@example.com"}, "1" => {"value" => ""},
+                                           "2" => {"value" => "countess@example.com"}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -2214,19 +2319,20 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # Two fields per handle, the service and the handle, each named by
-  # the line's digest; the scheme is the line's, like its parameters,
+  # A handle row is the phone row's shape, its service the label in
+  # the type column; the scheme is the line's, like its parameters,
   # and no field models it.
-  def test_the_edit_screen_renders_a_service_and_a_handle_field_per_handle
+  def test_the_edit_screen_renders_a_handle_under_its_service
     with_contacts({"ada" => SIGNAL}) do |store|
       get "/contacts/ada/edit"
 
       digest = store.contact("ada").handles.first.line.digest
       assert_includes last_response.body,
-        '<div class="field" data-blank-removes><span>handle</span><div class="handle-row">' \
-        '<input type="text" name="handle[' + digest + '][service]" value="signal" placeholder="service" aria-label="service">' \
-        '<input type="text" name="handle[' + digest + '][value]" value="ada.42" placeholder="handle" aria-label="handle">' \
-        '</div></div>'
+        '<div class="field" data-blank-removes>' \
+        '<input type="text" class="row-label" name="handle[' + digest + '][label]" value="signal" autocomplete="off" ' \
+        'list="handle-labels" placeholder="handle" aria-label="handle label">' \
+        '<input type="text" name="handle[' + digest + '][value]" value="ada.42" placeholder="removed on save" ' \
+        'aria-label="handle"></div>'
     end
   end
 
@@ -2234,7 +2340,7 @@ class AdminContactsPagesTest < Minitest::Test
     digest = store.contact("ada").handles.first.line.digest
     post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", note: "Countess of Lovelace.",
                              etag: store.contact("ada").etag,
-                             handle: {digest => {"service" => service, "value" => value}}
+                             handle: {digest => {"label" => service, "value" => value}}
     assert_equal 303, last_response.status
     store.contact("ada").vcard.to_s
   end
@@ -2290,15 +2396,17 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # Each handle row posts as a hash of its two fields; any other shape
-  # is a 400, the address rows' own rule.
-  def test_a_handle_field_in_a_shape_the_form_never_sends_is_a_400
+  # Each repeatable row posts as a hash of its label and its fields;
+  # any other shape is a 400, the address rows' own rule.
+  def test_a_row_field_in_a_shape_the_form_never_sends_is_a_400
     with_contacts({"ada" => SIGNAL}) do |store|
       contact = store.contact("ada")
       digest = contact.handles.first.line.digest
       [{handle: {digest => "ada.42"}},
        {handle: "ada.42"},
-       {new_handle: ["ada.42"]}].each do |field|
+       {new_handle: ["ada.42"]},
+       {phone: {"0" * 64 => "+1-555-0199"}},
+       {new_email: ["ada@example.com"]}].each do |field|
         post "/contacts/ada", {first: "Ada", last: "Lovelace", company: "0", etag: contact.etag}.merge(field)
 
         assert_equal 400, last_response.status, field
@@ -2313,9 +2421,9 @@ class AdminContactsPagesTest < Minitest::Test
   def test_added_handles_land_as_impp_lines_before_the_end
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               new_handle: {"0" => {"service" => "signal", "value" => "ada.42"},
-                                            "1" => {"service" => "signal", "value" => ""},
-                                            "2" => {"service" => "WhatsApp", "value" => "+1-555-0100"}}
+                               new_handle: {"0" => {"label" => "signal", "value" => "ada.42"},
+                                            "1" => {"label" => "signal", "value" => ""},
+                                            "2" => {"label" => "WhatsApp", "value" => "+1-555-0100"}}
 
       assert_equal 303, last_response.status
       assert_includes store.contact("ada").vcard.to_s,
@@ -2331,7 +2439,7 @@ class AdminContactsPagesTest < Minitest::Test
   def test_an_added_service_drops_what_a_parameter_cannot_carry
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               new_handle: {"0" => {"service" => %(Sig"nal), "value" => "ada.42"}}
+                               new_handle: {"0" => {"label" => %(Sig"nal), "value" => "ada.42"}}
 
       assert_equal 303, last_response.status
       assert_includes store.contact("ada").vcard.to_s, "IMPP;X-SERVICE-TYPE=Signal:x-apple:ada.42\r\n"
@@ -2340,7 +2448,7 @@ class AdminContactsPagesTest < Minitest::Test
 
   ## Editing addresses
 
-  # An address row: the type's caption and a stack of component
+  # An address row: the label field and a stack of component
   # fields in the value column, each named by the row's digest and
   # its component — and no po box field, which the save preserves
   # raw rather than letting a form near it.
@@ -2351,7 +2459,10 @@ class AdminContactsPagesTest < Minitest::Test
       digest = store.contact("ada").addresses.first.line.digest
       body = last_response.body
       assert_includes body,
-        '<div class="field" data-blank-removes><span>home</span><div class="field-stack">' \
+        '<div class="field" data-blank-removes>' \
+        '<input type="text" class="row-label" name="address[' + digest + '][label]" value="home" autocomplete="off" ' \
+        'list="address-labels" placeholder="address" aria-label="address label">' \
+        '<div class="field-stack">' \
         '<input type="text" name="address[' + digest + '][street]" value="12 Analytical Way" placeholder="street" aria-label="street">' \
         '<input type="text" name="address[' + digest + '][extended]" placeholder="street 2" aria-label="street 2">' \
         '<input type="text" name="address[' + digest + '][locality]" value="London" placeholder="city" aria-label="city">'
@@ -2812,15 +2923,20 @@ class AdminContactsPagesTest < Minitest::Test
       body = (get("/contacts/ada/edit") && last_response.body)
 
       assert_includes body, %(<div x-data="{ added: [], type: 'phone', company: false, firstBlank: false, lastBlank: false }">)
-      # One template serves every kind: the single-value kinds get an
-      # input whose type and name bind to the kind, the address kind
-      # the same six component fields a standing row gets — named by
-      # the add index, there being no line yet to digest.
+      # One template serves every kind, each the standing row's shape: a
+      # label field bound to the kind, then the single-value kinds an
+      # input whose type and name bind to it, the address kind the same
+      # six component fields a standing row gets — named by the add
+      # index, there being no line yet to digest.
       assert_includes body,
         '<template x-for="(kind, i) in added" :key="i"><div class="field">' \
-        '<span x-text="kind"></span>' \
-        '<template x-if="kind === \'phone\' || kind === \'email\'">' \
-        '<input :type="kind === \'phone\' ? \'tel\' : kind" :name="`new_${kind}[]`" :aria-label="kind" x-init="$el.focus()">' \
+        '<template x-if="kind === \'birthday\'"><span x-text="kind"></span></template>' \
+        '<template x-if="kind !== \'birthday\'">' \
+        '<input type="text" class="row-label" autocomplete="off" :name="`new_${kind}[${i}][label]`" ' \
+        ':list="`${kind}-labels`" :placeholder="kind" :aria-label="`${kind} label`"></template>' \
+        '<template x-if="{ phone: \'tel\', email: \'email\', handle: \'text\' }[kind]">' \
+        '<input :type="{ phone: \'tel\', email: \'email\', handle: \'text\' }[kind]" ' \
+        ':name="`new_${kind}[${i}][value]`" :aria-label="kind" x-init="$el.focus()">' \
         '</template>' \
         '<template x-if="kind === \'address\'">' \
         '<div class="field-stack">' \
@@ -2831,13 +2947,6 @@ class AdminContactsPagesTest < Minitest::Test
         '<input type="text" :name="`new_address[${i}][postal_code]`" placeholder="postal code" aria-label="postal code">' \
         '<input type="text" :name="`new_address[${i}][country]`" placeholder="country" aria-label="country">' \
         '</div></template>'
-      # The handle kind: the standing row's two fields, named by the
-      # add index as the address's are.
-      assert_includes body,
-        '<template x-if="kind === \'handle\'"><div class="handle-row">' \
-        '<input type="text" :name="`new_handle[${i}][service]`" placeholder="service" aria-label="service" x-init="$el.focus()">' \
-        '<input type="text" :name="`new_handle[${i}][value]`" placeholder="handle" aria-label="handle">' \
-        '</div></template>'
       # The birthday kind: the standing row's own controls with
       # nothing prefilled, naming the model's birthday[] group — no
       # digest, a contact holding at most one, and no removal state,
@@ -2871,7 +2980,8 @@ class AdminContactsPagesTest < Minitest::Test
   def test_a_single_save_adds_every_phone_that_was_typed
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               new_phone: ["+1-555-0177", "+1-555-0188", "", ""]
+                               new_phone: {"0" => {"value" => "+1-555-0177"}, "1" => {"value" => "+1-555-0188"},
+                                           "2" => {"value" => ""}, "3" => {"value" => ""}}
 
       assert_equal 303, last_response.status
       assert_equal ["+1-555-0100", "+1-555-0177", "+1-555-0188"],
@@ -2889,7 +2999,7 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => two}) 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 => "+1-555-0150"}
+                               phone: {digest => {"value" => "+1-555-0150"}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -2914,7 +3024,7 @@ class AdminContactsPagesTest < Minitest::Test
     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 => "+1-555-0150"}
+                               phone: {digest => {"value" => "+1-555-0150"}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -2933,7 +3043,7 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => apple}) 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 => "+1-555-0150"}
+                               phone: {digest => {"value" => "+1-555-0150"}}
 
       assert_equal 303, last_response.status
       assert_includes store.contact("ada").vcard.to_s,
@@ -2948,7 +3058,7 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => ADA}) 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 => " "}
+                               phone: {digest => {"value" => " "}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -2965,8 +3075,8 @@ class AdminContactsPagesTest < Minitest::Test
       before = store.contact("ada").stored.to_s
       digest = store.contact("ada").phones.first.line.digest
       post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
-                               company: "0", etag: store.contact("ada").etag, new_phone: [""],
-                               phone: {digest => " +1-555-0100 "}
+                               company: "0", etag: store.contact("ada").etag, new_phone: {"0" => {"value" => ""}},
+                               phone: {digest => {"value" => " +1-555-0100 "}}
 
       assert_equal 303, last_response.status
       assert_equal before, store.contact("ada").stored.to_s
@@ -2979,7 +3089,7 @@ class AdminContactsPagesTest < Minitest::Test
   def test_an_added_phone_lands_as_a_bare_tel_before_the_end
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               new_phone: ["+1 (555) 020-0700, x9"]
+                               new_phone: {"0" => {"value" => "+1 (555) 020-0700, x9"}}
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
@@ -2991,7 +3101,7 @@ class AdminContactsPagesTest < Minitest::Test
   def test_a_blank_add_row_inserts_nothing
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               new_phone: [" "]
+                               new_phone: {"0" => {"value" => " "}}
 
       assert_equal 303, last_response.status
       assert_equal 1, store.contact("ada").phones.length
@@ -3004,8 +3114,8 @@ class AdminContactsPagesTest < Minitest::Test
   def test_a_digest_the_card_never_had_is_ignored
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
-                               phone: {"0" * 64 => "+1-555-9999"},
-                               email: {"1" * 64 => "x@example.com"},
+                               phone: {"0" * 64 => {"value" => "+1-555-9999"}},
+                               email: {"1" * 64 => {"value" => "x@example.com"}},
                                address: {"2" * 64 => {"street" => "nowhere"}}
 
       assert_equal 303, last_response.status