admin: email and address rows on the edit screen, saved by digest
Assisted-by: GLM-5.3 via pi
change smxkvwxmqllqwwpwwlwwqqzlumxtmlvn
commit afcf7f2500c827df034928f5d186bb3a45ac52d6
author Alpha Chen <alpha@kejadlen.dev>
date
parent yzknxrst
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index fa3eb02..182af58 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -9,9 +9,9 @@ module ProTacts
     # rather than an always-editable page, one form over the
     # properties the save knows how to address. The cardinality-1 set
     # — name, nickname, note — saves through VCard#replace under each
-    # field; the phone rows save through VCard#substitute, each named
-    # by its line's digest. Emails and addresses arrive with their
-    # own stage, birthday last.
+    # field; the phone, email, and address rows save through
+    # VCard#substitute, each named by its line's digest. Birthday
+    # arrives last, with its own doc.
     #
     # The fields prefill from the accessors' unescaped readings, and
     # blank equals absent on the way back (Web#edited_card), so write
@@ -21,16 +21,34 @@ module ProTacts
     # entry point is the name, not the header search.
     class ContactsEdit < Phlex::HTML
       # The property types the add dialog offers, in the order it
-      # lists them. One for now: phones are the only rows the save can
-      # insert (docs/plans/2026-09-05-web-card-editor.md, stage 2),
-      # and a type listed here that the save cannot write would be a
-      # row that silently does nothing. Each name is both the radio's
-      # value and half the field name the row posts under
-      # (`new_<type>[]`), so adding a type here and a reader in
-      # Web#edited_card is the whole of adding one.
-      ADDABLE_TYPES = %w[phone].freeze #: Array[String]
+      # 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 both the radio's value
+      # and the row's field name: `new_<type>[]` for the single-value
+      # kinds, `new_address[<i>][<component>]` for the structured
+      # one, the bare [] unable to carry a component set (Rack
+      # refuses a key after an empty one).
+      ADDABLE_TYPES = %w[phone email address].freeze #: Array[String]
       private_constant :ADDABLE_TYPES
 
+      # The address row's component fields, stacked in the value
+      # column in the order an address form reads — RFC 2426 section
+      # 3.2.1's own order minus the po box, which no screen shows and
+      # the save splices around. Field name to placeholder: a
+      # component is not an attribute and earns no type column of its
+      # own, so the placeholder is the label and the conventional
+      # order carries it.
+      ADDRESS_FIELDS = [
+        ["street", "street"],
+        ["extended", "street 2"],
+        ["locality", "city"],
+        ["region", "region"],
+        ["postal_code", "postal code"],
+        ["country", "country"],
+      ].freeze #: Array[[String, String]]
+      private_constant :ADDRESS_FIELDS
+
       # @rbs @contact: Contact
       # @rbs @notice: String?
       # @rbs @first: String?
@@ -111,6 +129,14 @@ module ProTacts
                       input(type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
                     end
                   end
+                  # An email row, the phone row's own shape over EMAIL.
+                  @contact.emails.each do |email|
+                    label(class: "field") do
+                      span { email.type || "email" }
+                      input(type: "email", name: "email[#{email.line.digest}]", value: email.value)
+                    end
+                  end
+                  @contact.addresses.each { address_row(it) }
                   added_rows
                   label(class: "field") do
                     span { "Note" }
@@ -143,12 +169,11 @@ module ProTacts
       # That verb — make a row, now, without a round trip — is what
       # CSS could not do and what Alpine is here for (see Layout).
       #
-      # The caption is the untyped fallback ContactsShow uses for a
-      # property with no TYPE, which is what these lines are until a
-      # client gives them one. The name is the type's own list
-      # parameter, so the save reads `new_phone[]` today and
-      # `new_email[]` the day the dialog offers it, with no new
-      # wiring between here and Web#edited_phones.
+      # 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.
       #
       # `x-init` on the input rather than autofocus: it runs when the
       # element is created, which is exactly when the row is added,
@@ -156,10 +181,51 @@ module ProTacts
       # starts empty.
       def added_rows
         template(x_for: "(kind, i) in added", ":key": "i") do
-          label(class: "field") do
+          div(class: "field") do
             span("x-text": "kind")
-            input(type: "tel", ":name": "`new_${kind}[]`",
-                  "x-init": "$el.focus()")
+            # 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 !== 'address'") do
+              input(":type": "kind === 'phone' ? 'tel' : kind", ":name": "`new_${kind}[]`",
+                    ":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|
+                  input(type: "text", ":name": "`new_address[${i}][#{component}]`",
+                        placeholder: label, aria_label: label,
+                        "x-init": index.zero? ? "$el.focus()" : nil)
+                end
+              end
+            end
+          end
+        end
+      end
+
+      # An address row: the type's caption 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.
+      #: (Contact::Address address) -> void
+      def address_row(address)
+        div(class: "field") do
+          span { address.type || "address" }
+          div(class: "field-stack") do
+            ADDRESS_FIELDS.each do |component, label|
+              input(type: "text", name: "address[#{address.line.digest}][#{component}]",
+                    value: address.public_send(component), placeholder: label, aria_label: label)
+            end
           end
         end
       end
@@ -180,11 +246,8 @@ module ProTacts
       # the form, and what it changes is visible in the card the
       # moment the popover closes.
       #
-      # A radio group with one option reads oddly today and is the
-      # point: ADDABLE_TYPES is the list, emails and addresses join it
-      # when the save can insert them (docs/plans/2026-09-05-web-card-editor.md,
-      # stage 3), and neither this method nor the row template changes
-      # when they do.
+      # The three options are the three kinds of row the save can
+      # insert; a fourth, birthday, arrives with its own doc.
       #
       # The radios share a `name` so they are one native group —
       # arrow-key navigation and "1 of n" come from that, not from
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 888029c..860a417 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -34,6 +34,21 @@ module ProTacts
       __dir__ #: String
     ).parent.parent / "public" #: Pathname
 
+    # ADR's editable components: field name to position in the value
+    # (RFC 2426 section 3.2.1, minus the leading po box at position 0
+    # — no screen shows one, and the save preserves its bytes rather
+    # than letting a form field near them). In the value's own order,
+    # which is the order a rebuilt line's components join in.
+    ADDRESS_COMPONENTS = {
+      "extended" => 1,
+      "street" => 2,
+      "locality" => 3,
+      "region" => 4,
+      "postal_code" => 5,
+      "country" => 6,
+    }.freeze #: Hash[String, Integer]
+    private_constant :ADDRESS_COMPONENTS
+
     # The store this app serves from. config.ru builds it and hands it in;
     # nothing here reaches for a global to find one, which is what lets a
     # test point the app at a throwaway database. Kept in Roda's own opts
@@ -706,7 +721,9 @@ module ProTacts
       card = card.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
       card = card.replace("NICKNAME", text_lines("NICKNAME", nickname))
       card = card.replace("NOTE", text_lines("NOTE", note))
-      edited_phones(contact, card, params)
+      card = edited_phones(contact, card, params)
+      card = edited_emails(contact, card, params)
+      edited_addresses(contact, card, params)
     end
 
     # The phones' half of the surgical save: each row names its line
@@ -755,6 +772,118 @@ module ProTacts
       card.insert(added)
     end
 
+    # The emails' half of the surgical save: the phones' own walk over
+    # EMAIL — each row names its line by digest, an unchanged row is
+    # skipped, a changed one swaps the value under the line's own
+    # header, a blank removes the line, and the add dialog's rows land
+    # as bare EMAIL lines before END:VCARD.
+    #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
+    def edited_emails(contact, card, params)
+      rows = params["email"]
+      if rows.is_a?(Hash)
+        contact.emails.each do |email|
+          property = email.line.property
+          next if property.nil?
+
+          submitted = rows[email.line.digest]
+          next if submitted.nil? || submitted.to_s.strip == email.value
+
+          value = submitted.to_s.strip
+          card = card.substitute(
+            email.line.digest,
+            value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
+          )
+        end
+      end
+
+      submitted = Array(params["new_email"]) #: Array[untyped]
+      added = submitted.filter_map do
+        value = it.to_s.strip
+        "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
+      end #: Array[String]
+      card.insert(added)
+    end
+
+    # The addresses' half of the surgical save: the same
+    # digest-addressed walk, over a row that is six fields rather
+    # than one (Admin::ContactsEdit). A row unchanged throughout is
+    # skipped whole — the phones' rule, keeping an untouched line's
+    # bytes its own — and a changed one is rebuilt by the splice
+    # below. 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 card, Hash[String, untyped] params) -> VCard
+    def edited_addresses(contact, card, params)
+      rows = params["address"]
+      if rows.is_a?(Hash)
+        contact.addresses.each do |address|
+          # The property is nil only for a line that would not read —
+          # the phones' own guard.
+          property = address.line.property
+          next if property.nil?
+
+          submitted = rows[address.line.digest]
+          next if !submitted.is_a?(Hash) || address_unchanged?(address, submitted)
+
+          line = address_line(address, submitted)
+          card = card.substitute(address.line.digest, line ? [line] : [])
+        end
+      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.
+      submitted = params["new_address"]
+      added = submitted.is_a?(Hash) ? submitted.values : Array(submitted) #: Array[untyped]
+      card.insert(added.filter_map { address_line(nil, it) })
+    end
+
+    # A row is unchanged when every field submits its current
+    # reading — the phones' skip at the component grain.
+    #: (Contact::Address address, untyped submitted) -> bool
+    def address_unchanged?(address, submitted)
+      ADDRESS_COMPONENTS.keys.all? do |name|
+        submitted[name].to_s.strip == address.public_send(name).to_s
+      end
+    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, untyped submitted) -> String?
+    def address_line(address, submitted)
+      return unless submitted.is_a?(Hash)
+
+      property = address && address.line.property
+      if property.nil?
+        components = [""]
+      else
+        components = VCard.split_raw_components(property.value)
+      end
+      components << "" while components.length < 7
+      ADDRESS_COMPONENTS.each do |name, position|
+        value = submitted[name].to_s.strip
+        current = address && address.public_send(name).to_s
+        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(";")}\r\n"
+    end
+
     # A text property's replacement lines: the escaped value (RFC 2426
     # section 2.4.2 — the value is text, the line is structure), or none
     # at all — blank equals absent, the whole-property rule.
diff --git a/public/admin.css b/public/admin.css
index 3f1e39a..072966f 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -77,8 +77,11 @@
    popover is not a record, and the two should not diverge just
    because one of them opens over this screen. */
 .card-body form.field-stack { gap: var(--gl-space-2xs); }
-.card-body form.field-stack > label.field > input,
-.card-body form.field-stack > label.field > textarea { padding-block: var(--gl-space-2xs); }
+.card-body form.field-stack > .field > input,
+.card-body form.field-stack > .field > textarea,
+.card-body form.field-stack > .field > .field-stack > input {
+  padding-block: var(--gl-space-2xs);
+}
 
 /* An editor row is a detail row that happens to be editable: caption
    in the type column, control in the value column, on .detail-grid's
@@ -90,13 +93,27 @@
    eight rows deep before a contact has an email. Baseline for the
    reason .detail-grid gives — caption and control carry different
    type sizes, so lining their boxes up leaves their text on
-   different lines. */
-.card-body form.field-stack > label.field {
+   different lines. The class rather than the element: a row is a
+   label when it holds one control and a div when it holds a stack
+   of them (an address — see the nested-stack rule below), and both
+   are the record's rows. */
+.card-body form.field-stack > .field {
   display: grid;
   grid-template-columns: var(--type-column) 1fr;
   column-gap: var(--gl-space-s);
   align-items: baseline;
 }
+/* A row whose control is a stack of inputs — an address's component
+   fields — holds the multi-line value's own lines in the value
+   column (docs/DESIGN.md), so the stack closes to the record's own
+   row gap and each input sheds the same block padding every other
+   row's control sheds (the selector above it). Reached by descent
+   from the record's row, which the nested stack is part of; the
+   dialogs' fields are no form.field-stack's children and keep
+   Gloss's own. */
+.card-body form.field-stack .field > .field-stack {
+  gap: var(--gl-space-2xs);
+}
 /* A multiline control has no single line to align a caption to, and
    what a browser calls its baseline is not settled — a scroll
    container's can be synthesized from its border box, which would
@@ -105,10 +122,10 @@
    holds the textarea's first line off its own edge: the block
    padding above plus the border. Derived, not eyeballed, so the two
    move together if either changes. */
-.card-body form.field-stack > label.field:has(textarea) {
+.card-body form.field-stack > .field:has(textarea) {
   align-items: start;
 }
-.card-body form.field-stack > label.field:has(textarea) > span {
+.card-body form.field-stack > .field:has(textarea) > span {
   padding-block-start: calc(var(--gl-space-2xs) + 1px);
 }
 
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 60ca8c8..ac95caf 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -781,6 +781,284 @@ class AdminContactsPagesTest < Minitest::Test
     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.
+  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"><span>home</span><input type="email" name="email[' + digest + ']" value="ada@example.com"></label>'
+    end
+  end
+
+  # The addressed edit at work over EMAIL: the submitted row swaps its
+  # line's value under the line's own header, and every other byte of
+  # the stored card — the other EMAIL-able lines included — stands
+  # untouched.
+  def test_saving_a_changed_email_replaces_only_its_line
+    two = ADA.sub("EMAIL;TYPE=home:ada@example.com\r\n",
+                  "EMAIL;TYPE=home:ada@example.com\r\nEMAIL;TYPE=work:countess@example.com\r\n")
+
+    with_contacts({"ada" => two}) do |store|
+      digest = store.contact("ada").emails.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               email: {digest => "lovelace@example.com"}
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "EMAIL;TYPE=home:lovelace@example.com\r\n"
+      assert_includes card, "EMAIL;TYPE=work:countess@example.com\r\n"
+      refute_includes card, "ada@example.com"
+    end
+  end
+
+  # The header is the line's own, kept byte for byte — the phones'
+  # own rule, over the parameters an EMAIL line can carry.
+  def test_a_saved_email_keeps_its_lines_own_parameters
+    apple = ADA.sub("EMAIL;TYPE=home:ada@example.com", "EMAIL;type=INTERNET;type=HOME;type=pref:ada@example.com")
+
+    with_contacts({"ada" => apple}) do |store|
+      digest = store.contact("ada").emails.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               email: {digest => "lovelace@example.com"}
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s,
+        "EMAIL;type=INTERNET;type=HOME;type=pref:lovelace@example.com\r\n"
+    end
+  end
+
+  # Blank equals absent for a row with a digest: the blank removes
+  # the line, the phones' own rule.
+  def test_a_blank_email_row_removes_the_line
+    with_contacts({"ada" => ADA}) do |store|
+      digest = store.contact("ada").emails.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               email: {digest => " "}
+
+      assert_equal 303, last_response.status
+      refute_includes store.contact("ada").vcard.to_s, "EMAIL"
+    end
+  end
+
+  # An unchanged row never rewrites its line, and the whole save
+  # leaves the stored card byte for byte as it lay.
+  def test_an_unchanged_email_and_address_leave_the_stored_card_byte_identical
+    with_contacts({"ada" => ADA}) do |store|
+      before = store.contact("ada").stored.to_s
+      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.",
+                               etag: store.contact("ada").etag, new_email: "",
+                               email: {email_digest => "ada@example.com"},
+                               address: {address_digest => {
+                                 "street" => "12 Analytical Way", "locality" => "London",
+                                 "region" => "England", "postal_code" => "NW1 1AA",
+                                 "country" => "United Kingdom",
+                               }}
+
+      assert_equal 303, last_response.status
+      assert_equal before, store.contact("ada").stored.to_s
+    end
+  end
+
+  # The revealed row: a value lands as a bare EMAIL before END:VCARD,
+  # and a blank one inserts nothing — the phones' own rules.
+  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", etag: store.contact("ada").etag,
+                               new_email: ["lovelace@example.com", "", "countess@example.com"]
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_equal ["ada@example.com", "lovelace@example.com", "countess@example.com"],
+        store.contact("ada").emails.map(&:value)
+      assert_includes card, "EMAIL:countess@example.com\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n"
+    end
+  end
+
+  ## Editing addresses
+
+  # An address row: the type's caption 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.
+  def test_the_edit_screen_renders_component_fields_per_address
+    with_contacts({"ada" => ADA}) do |store|
+      get "/contacts/ada/edit"
+
+      digest = store.contact("ada").addresses.first.line.digest
+      body = last_response.body
+      assert_includes body,
+        '<div class="field"><span>home</span><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">'
+      assert_includes body,
+        'name="address[' + digest + '][postal_code]" value="NW1 1AA" placeholder="postal code"'
+      assert_includes body,
+        'name="address[' + digest + '][country]" value="United Kingdom" placeholder="country"'
+      refute_includes body, "po_box"
+    end
+  end
+
+  # The addressed edit at work over ADR: the submitted components
+  # swap under the line's own header, and every other byte of the
+  # stored card stands untouched.
+  def test_saving_a_changed_address_replaces_only_its_line
+    with_contacts({"ada" => ADA}) do |store|
+      digest = store.contact("ada").addresses.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               address: {digest => {
+                                 "street" => "5 Analytical Way", "locality" => "London",
+                                 "region" => "England", "postal_code" => "NW1 1AA",
+                                 "country" => "United Kingdom",
+                               }}
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "ADR;TYPE=home:;;5 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
+      assert_includes card, "TEL;TYPE=mobile:+1-555-0100\r\n"
+      assert_includes card, "EMAIL;TYPE=home:ada@example.com\r\n"
+
+      follow_redirect!
+      assert_includes last_response.body, "5 Analytical Way"
+    end
+  end
+
+  # The header is the line's own, kept byte for byte — the phones'
+  # own rule, over the parameters an ADR line can carry.
+  def test_a_saved_address_keeps_its_lines_own_parameters
+    apple = ADA.sub(
+      "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+      "ADR;type=HOME;type=pref:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+    )
+
+    with_contacts({"ada" => apple}) do |store|
+      digest = store.contact("ada").addresses.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               address: {digest => {
+                                   "street" => "5 Analytical Way", "locality" => "London",
+                                   "region" => "England", "postal_code" => "NW1 1AA",
+                                   "country" => "United Kingdom",
+                                 }}
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s,
+        "ADR;type=HOME;type=pref:;;5 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
+    end
+  end
+
+  # Removal is the reader's own rule: a row blank throughout, po box
+  # included, removes the line.
+  def test_an_address_row_blank_throughout_removes_the_line
+    with_contacts({"ada" => ADA}) do |store|
+      digest = store.contact("ada").addresses.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               address: {digest => {
+                                   "street" => " ", "extended" => "", "locality" => "",
+                                   "region" => "", "postal_code" => "", "country" => "",
+                                 }}
+
+      assert_equal 303, last_response.status
+      refute_includes store.contact("ada").vcard.to_s, "ADR"
+    end
+  end
+
+  # A partially blanked row keeps its line — partial blanks are legal
+  # empty components, the reader's own rule in the other direction.
+  def test_a_partially_blanked_address_keeps_the_line
+    with_contacts({"ada" => ADA}) do |store|
+      digest = store.contact("ada").addresses.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               address: {digest => {
+                                   "street" => "12 Analytical Way", "locality" => "London",
+                                   "region" => "", "postal_code" => "", "country" => "",
+                                 }}
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s,
+        "ADR;TYPE=home:;;12 Analytical Way;London;;;\r\n"
+    end
+  end
+
+  # The po box and any component the form left alone keep their own
+  # bytes — n_line's splice rule at the component grain, where a
+  # rebuild through unescape and re-escape is not byte-stable (the
+  # unrecognized `\\q` escape would come back doubled).
+  def test_the_addresss_untouched_components_are_preserved_byte_for_byte
+    po_box = ADA.sub(
+      "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+      "ADR;TYPE=home:P.O. Box 5;Apt \\q;12 Way\\, West;London;England;NW1 1AA;United Kingdom",
+    )
+
+    with_contacts({"ada" => po_box}) do |store|
+      digest = store.contact("ada").addresses.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               address: {digest => {
+                                   "street" => "12 Way, West", "extended" => "Apt \\q",
+                                   "locality" => "Manchester", "region" => "England",
+                                   "postal_code" => "NW1 1AA", "country" => "United Kingdom",
+                                 }}
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s,
+        "ADR;TYPE=home:P.O. Box 5;Apt \\q;12 Way\\, West;Manchester;England;NW1 1AA;United Kingdom\r\n"
+    end
+  end
+
+  # A component is text and the value is structure: punctuation
+  # escapes in the written line (RFC 2426 section 2.4.2).
+  def test_address_punctuation_escapes_in_the_written_line
+    with_contacts({"ada" => ADA}) do |store|
+      digest = store.contact("ada").addresses.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               address: {digest => {
+                                   "street" => "12 Way, East; Annexe", "locality" => "London",
+                                   "region" => "England", "postal_code" => "NW1 1AA",
+                                   "country" => "United Kingdom",
+                                 }}
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s,
+        "ADR;TYPE=home:;;12 Way\\, East\\; Annexe;London;England;NW1 1AA;United Kingdom\r\n"
+    end
+  end
+
+  # The revealed address row: the components land in the value's own
+  # order — po box empty, extended before street (RFC 2426 section
+  # 3.2.1) — whatever order the form stacks them in, and a row blank
+  # throughout inserts nothing, the new-row rule.
+  def test_an_added_address_lands_in_component_order_before_the_end
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               new_address: {"1" => {
+                                   "street" => "5 Analytical Way", "extended" => "Apt 9",
+                                   "locality" => "London", "country" => "United Kingdom",
+                                 }}
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "ADR:;Apt 9;5 Analytical Way;London;;;United Kingdom\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n"
+      assert_includes card, "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
+    end
+  end
+
+  def test_a_blank_address_add_row_inserts_nothing
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               new_address: {"0" => {"street" => " ", "country" => ""}}
+
+      assert_equal 303, last_response.status
+      assert_equal 1, store.contact("ada").addresses.length
+    end
+  end
+
   ## Adding a property
 
   # The add affordance: a trigger on the back link's line — the place
@@ -795,13 +1073,14 @@ class AdminContactsPagesTest < Minitest::Test
         '<a href="/contacts/ada" class="type-label">‹ Ada Lovelace</a>' \
         '<button type="button" data-size="sm" popovertarget="add-property">add property</button></div>'
       assert_includes body, '<dialog id="add-property" popover="auto">'
-      # A radio group of one today: ADDABLE_TYPES is what grows, and
-      # neither the dialog nor the row template changes when it does.
-      # The shared `name` is what makes them a native group; it is
-      # never submitted, the dialog having no form owner.
+      # A radio group of every kind the save can insert: ADDABLE_TYPES
+      # is the list. The shared `name` is what makes them a native
+      # group; it is never submitted, the dialog having no form owner.
       assert_includes body,
         '<div class="field-stack" role="radiogroup" aria-label="Type">' \
-        '<label><input type="radio" name="add-type" value="phone" x-model="type">phone</label></div>'
+        '<label><input type="radio" name="add-type" value="phone" x-model="type">phone</label>' \
+        '<label><input type="radio" name="add-type" value="email" x-model="type">email</label>' \
+        '<label><input type="radio" name="add-type" value="address" x-model="type">address</label></div>'
       assert_includes body,
         '<button type="button" popovertarget="add-property" popovertargetaction="hide">Cancel</button>'
       # Add makes the row and then hides the popover, in that order —
@@ -819,10 +1098,25 @@ class AdminContactsPagesTest < Minitest::Test
       body = (get("/contacts/ada/edit") && last_response.body)
 
       assert_includes body, %(<div x-data="{ added: [], type: 'phone' }">)
+      # 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.
       assert_includes body,
-        '<template x-for="(kind, i) in added" :key="i"><label class="field">' \
+        '<template x-for="(kind, i) in added" :key="i"><div class="field">' \
         '<span x-text="kind"></span>' \
-        '<input type="tel" :name="`new_${kind}[]`" x-init="$el.focus()"></label></template>'
+        '<template x-if="kind !== \'address\'">' \
+        '<input :type="kind === \'phone\' ? \'tel\' : kind" :name="`new_${kind}[]`" :aria-label="kind" x-init="$el.focus()">' \
+        '</template>' \
+        '<template x-if="kind === \'address\'">' \
+        '<div class="field-stack">' \
+        '<input type="text" :name="`new_address[${i}][street]`" placeholder="street" aria-label="street" x-init="$el.focus()">' \
+        '<input type="text" :name="`new_address[${i}][extended]`" placeholder="street 2" aria-label="street 2">' \
+        '<input type="text" :name="`new_address[${i}][locality]`" placeholder="city" aria-label="city">' \
+        '<input type="text" :name="`new_address[${i}][region]`" placeholder="region" aria-label="region">' \
+        '<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></div></template>'
       # The template is inside the edit form, so one Save writes every
       # row it made along with everything else. Indexed from the
       # form's start, the header's search form closing before both.
@@ -945,16 +1239,21 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # The addresses come from the contact, never the request: a
-  # doctored digest names no row and touches nothing, where a
-  # doctored index into a list would have rewritten some other line.
+  # The addresses and emails come from the contact, never the
+  # request: a doctored digest names no row and touches nothing, where
+  # a doctored index into a list would have rewritten some other line.
   def test_a_digest_the_card_never_had_is_ignored
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
-                               phone: {"0" * 64 => "+1-555-9999"}
+                               phone: {"0" * 64 => "+1-555-9999"},
+                               email: {"1" * 64 => "x@example.com"},
+                               address: {"2" * 64 => {"street" => "nowhere"}}
 
       assert_equal 303, last_response.status
-      assert_includes store.contact("ada").vcard.to_s, "TEL;TYPE=mobile:+1-555-0100\r\n"
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "TEL;TYPE=mobile:+1-555-0100\r\n"
+      assert_includes card, "EMAIL;TYPE=home:ada@example.com\r\n"
+      assert_includes card, "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
     end
   end