contacts: take a middle name in the create and edit forms
N held a middle all along, but no box let you type it;
three boxes now, and FN grows the moment you supply it.
A box handing back what it read leaves the stored bytes as they stand;
the importer still refuses one, so no plan carries a middle in hand.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change kqswrvltmyoottsxvwyknsxvlsqtomqp
commit 1b19fadc66198516d6c01ceb75b810bcd092d2f4
author Alpha Chen <alpha@kejadlen.dev>
date
parent rlzmytto
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 1568afb..e248f77 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -36,20 +36,20 @@ module ProTacts
       # record later. The names escape, because a name is text — a
       # comma or semicolon in one must not read as structure (RFC 2426
       # section 2.4.2).
-      #: (String id, String first, String last) -> VCard
-      def self.new_card(id, first, last)
+      #: (String id, String first, String middle, String last) -> VCard
+      def self.new_card(id, first, middle, last)
         VCard.new(
           "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
-            "N:#{VCard.escape(last)};#{VCard.escape(first)};;;\r\n" \
-            "FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n" \
+            "N:#{VCard.escape(last)};#{VCard.escape(first)};#{VCard.escape(middle)};;\r\n" \
+            "FN:#{VCard.escape(display_name(first, middle, last))}\r\n" \
             "UID:#{id}\r\n" \
             "END:VCARD\r\n",
         )
       end
 
       # The contact editor's save, spliced into the stored card.
-      #: (Contact contact, String first, String last, Hash[String, untyped] params) -> VCard
-      def self.contact_card(contact, first, last, params)
+      #: (Contact contact, String first, String middle, String last, Hash[String, untyped] params) -> VCard
+      def self.contact_card(contact, first, middle, last, params)
         nickname = params["nickname"].to_s.strip
         note = params["note"].to_s.strip
 
@@ -61,8 +61,8 @@ module ProTacts
         own = contact.own
 
         vcard = contact.stored
-        vcard = vcard.replace("N", [n_line(vcard, first, last)])
-        vcard = edited_fn(contact, vcard, first, last)
+        vcard = vcard.replace("N", [n_line(vcard, first, middle, last)])
+        vcard = edited_fn(contact, vcard, first, middle, last)
         vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
         vcard = vcard.replace("NOTE", text_lines("NOTE", note))
         vcard = edited_phones(own, vcard, params)
@@ -284,36 +284,57 @@ module ProTacts
       # name is stale. A card carrying no FN is the other case: the
       # property is mandatory (section 4), so a save fills it in rather
       # than leaving the absence it found.
-      #: (Contact contact, VCard vcard, String first, String last) -> VCard
-      def self.edited_fn(contact, vcard, first, last)
-        family, given = contact.name_components || []
+      #: (Contact contact, VCard vcard, String first, String middle, String last) -> VCard
+      def self.edited_fn(contact, vcard, first, middle, last)
+        family, given, additional = contact.name_components || []
         named = vcard.properties.any? { it.name.casecmp?("FN") }
-        return vcard if named && first == given.to_s && last == family.to_s
+        return vcard if named && first == given.to_s && middle == additional.to_s && last == family.to_s
 
-        vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
+        vcard.replace("FN", ["FN:#{VCard.escape(display_name(first, middle, last))}\r\n"])
       end
 
-      # N's replacement line. The first two components are the form's;
-      # the remaining three — additional, prefixes, suffixes (RFC 2426
-      # section 3.1.2) — rejoin byte for byte, which is what the splice
-      # over the still-escaped value buys
-      # (docs/plans/2026-09-05-web-card-editor.md). A card with no N a
-      # form can read splices into a bare five-component value; one
-      # short of five is padded, the same empties a whole-N writer
-      # would leave.
-      #: (VCard vcard, String first, String last) -> String
-      def self.n_line(vcard, first, last)
+      # FN's text, from the boxes that make it: the western order the
+      # create dialog has always written (Admin::ContactDialog), each
+      # blank box left out rather than spacing the line.
+      #: (String first, String middle, String last) -> String
+      def self.display_name(first, middle, last)
+        [first, middle, last].reject(&:empty?).join(" ")
+      end
+
+      # N's replacement line. The first three components are the form's
+      # — family, given, additional (RFC 2426 section 3.1.2) — and the
+      # prefixes and suffixes after them rejoin byte for byte, which is
+      # 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
+      # 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
+      # five-component value; one short of five is padded, the same
+      # empties a whole-N writer would leave.
+      #: (VCard vcard, String first, String middle, String last) -> String
+      def self.n_line(vcard, first, middle, last)
         property = vcard.properties.find { it.name.casecmp?("N") }
         components = [] #: Array[String]
-        components.replace(VCard.split_raw_components(property.value)) if property
+        readings = [] #: Array[String]
+        if property
+          components.replace(VCard.split_raw_components(property.value))
+          readings.replace(property.components)
+        end
         components << "" while components.length < 5
-        components[0] = VCard.escape(last)
-        components[1] = VCard.escape(first)
+        # The form's three, in the value's own order: family, given,
+        # additional.
+        [last, first, middle].each_with_index do |value, position|
+          current = readings[position].to_s
+          components[position] = value == current ? components.fetch(position) : VCard.escape(value)
+        end
         "N:#{components.join(";")}\r\n"
       end
 
       private_class_method :edited_phones, :edited_emails, :edited_addresses, :address_unchanged?,
-                           :address_line, :birthday_component, :text_lines, :edited_fn, :n_line
+                           :address_line, :birthday_component, :text_lines, :edited_fn, :display_name,
+                           :n_line
     end
   end
 end
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index dfa9c60..47aac8b 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -27,9 +27,11 @@ module ProTacts
     # a contact carries arrives by editing the record — which belongs
     # on the details page, where the surgical mutator will live. N and
     # FN both written because RFC 2426 section 4 requires them of every
-    # card; the two name fields are N's first two components
-    # (family;given), and FN is derived from them in the western order
-    # the fixture sessions show macOS writing.
+    # card; the three name fields are N's first three components
+    # (family;given;additional), and FN is derived from them in the
+    # western order the fixture sessions show macOS writing. The middle
+    # box stands outside the required pair — it is never the name a
+    # contact is made by — so it carries no rule of NamePair's.
     #
     # A popover cannot be declared open in markup, so the empty-name
     # refusal is not a re-opened dialog: each name box is `required`
@@ -56,6 +58,10 @@ module ProTacts
               plain "First"
               input(**NamePair.first(nil, nil), autofocus: true)
             end
+            label(class: "field") do
+              plain "Middle"
+              input(type: "text", name: "middle")
+            end
             label(class: "field") do
               plain "Last"
               input(**NamePair.last(nil, nil))
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 9775962..de2de01 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -87,6 +87,7 @@ module ProTacts
       # @rbs @own: Contact
       # @rbs @notice: String?
       # @rbs @first: String?
+      # @rbs @middle: String?
       # @rbs @last: String?
 
       #: (contact: Contact, ?notice: String?) -> void
@@ -98,12 +99,14 @@ module ProTacts
         # composed contact's, the one a save is guarded against.
         @own = contact.own
         @notice = notice
-        # N's first two components (RFC 2426 section 3.1.2: family;
-        # given) — the two fields the create dialog also asks for. The
-        # remaining three are preserved byte-for-byte by the save's
-        # raw splice (CardForm.n_line), never rendered here.
-        family, given = contact.name_components || []
+        # N's first three components (RFC 2426 section 3.1.2: family;
+        # given; additional) — the three fields the create dialog also
+        # asks for. The prefixes and suffixes after them are preserved
+        # byte-for-byte by the save's raw splice (CardForm.n_line),
+        # never rendered here.
+        family, given, additional = contact.name_components || []
         @first = given
+        @middle = additional
         @last = family
       end
 
@@ -143,6 +146,15 @@ module ProTacts
                     span { "First" }
                     input(**NamePair.first(@first, @last), autofocus: true)
                   end
+                  # Outside the required pair, and outside its removal
+                  # state with it: a blanked middle name rewrites N's
+                  # additional component like any other name box, and
+                  # the row it would strike through is the name row,
+                  # which no save can remove.
+                  label(class: "field") do
+                    span { "Middle" }
+                    input(type: "text", name: "middle", value: @middle)
+                  end
                   label(class: "field") do
                     span { "Last" }
                     input(**NamePair.last(@first, @last))
diff --git a/lib/pro_tacts/admin/name_pair.rb b/lib/pro_tacts/admin/name_pair.rb
index 2cf5ac7..e2e0a57 100644
--- a/lib/pro_tacts/admin/name_pair.rb
+++ b/lib/pro_tacts/admin/name_pair.rb
@@ -9,6 +9,10 @@ module ProTacts
     # they change. The server's refusal of a save blank throughout
     # (Web's contacts routes) is the backstop behind it.
     #
+    # The middle box the same forms carry is not one of the halves and
+    # is not here: nobody is known by a middle name alone, so it is
+    # never required and has no state to share.
+    #
     # Not a view, so the Steepfile checks it.
     module NamePair
       # The Alpine state the two boxes share, as properties for the
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index d8aeddb..024094d 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -99,10 +99,13 @@ module ProTacts
       # make of these fields. The birthday rides beside the card as the
       # store keeps it, and Contact composes the line back in
       # (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+      # No middle name: a plan's card holds first and last, and a source
+      # N carrying anything past them is refused rather than imported
+      # (Macos.name), so there is none here to write.
       #: (String id) -> Contact
       def contact(id)
-        created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, last), birthday: nil, inherited: [])
-        stored = Admin::CardForm.contact_card(created, first, last, {
+        created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, "", last), birthday: nil, inherited: [])
+        stored = Admin::CardForm.contact_card(created, first, "", last, {
           "nickname" => nickname.to_s, "note" => note.to_s,
           "new_phone" => phones, "new_email" => emails, "new_address" => addresses
         })
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index bc6ab0d..b81b212 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -32,14 +32,19 @@ module ProTacts
       r.is do
         r.post do
           first = r.params["first"].to_s.strip
+          middle = r.params["middle"].to_s.strip
           last = r.params["last"].to_s.strip
+          # A middle name is not one of the halves that make a name: the
+          # form asks for it beside a pair it stands outside of
+          # (Admin::NamePair), and nobody is known by their middle name
+          # alone.
           if first.empty? && last.empty?
             dashboard(query: r.params["q"], notice: "A contact needs a name.")
           else
             # Store#put overwrites, so the width has to make a collision
             # unthinkable rather than caught: 48 bits, for a family's book.
             id = ChangeId.mint(12)
-            store.put(id, Admin::CardForm.new_card(id, first, last))
+            store.put(id, Admin::CardForm.new_card(id, first, middle, last))
             r.redirect "/contacts/#{id}", 303
           end
         end
@@ -132,6 +137,7 @@ module ProTacts
       # name pair (Admin::NamePair) being the browser's own refusal of
       # the same.
       first = r.params["first"].to_s.strip
+      middle = r.params["middle"].to_s.strip
       last = r.params["last"].to_s.strip
       return edit_screen(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
 
@@ -170,7 +176,7 @@ module ProTacts
         return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
       end
 
-      store.rewrite(id, Admin::CardForm.contact_card(contact, first, last, r.params), birthday:)
+      store.rewrite(id, Admin::CardForm.contact_card(contact, first, middle, last, r.params), birthday:)
       r.redirect "/contacts/#{id}", 303
     end
 
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index cdbbbbb..ef7c25d 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -837,6 +837,7 @@ class AdminContactsPagesTest < Minitest::Test
       # assertion keeps a regression from rendering a silent label).
       assert_includes body, '<label class="field">First<input type="text" name="first" required ' \
                             ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus></label>'
+      assert_includes body, '<label class="field">Middle<input type="text" name="middle"></label>'
       assert_includes body, '<label class="field">Last<input type="text" name="last" required ' \
                             ':required="firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
       assert_includes body, 'popovertargetaction="hide"'
@@ -868,6 +869,32 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The middle box writes N's additional component (RFC 2426 section
+  # 3.1.2) and joins the display name the other two make.
+  def test_creating_a_contact_with_a_middle_name
+    with_contacts({}) do |store|
+      post "/contacts", first: "Ada", middle: "Byron", last: "Lovelace"
+
+      assert_equal 303, last_response.status
+      id = last_response["Location"].delete_prefix("/contacts/")
+      card = store.contact(id).vcard.to_s
+      assert_includes card, "N:Lovelace;Ada;Byron;;"
+      assert_includes card, "FN:Ada Byron Lovelace"
+    end
+  end
+
+  # A middle name is not one of the halves a contact is named by, so a
+  # create carrying nothing else is the nameless one, refused.
+  def test_a_create_with_only_a_middle_name_is_refused
+    with_contacts({}) do |store|
+      post "/contacts", first: "", middle: "Byron", last: ""
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "A contact needs a name."
+      assert_empty store.contacts
+    end
+  end
+
   # A single name is a fine contact: the model carries either half
   # alone, and the card says exactly what was given.
   def test_creating_a_contact_with_only_a_first_name
@@ -928,6 +955,7 @@ class AdminContactsPagesTest < Minitest::Test
       assert_includes body, '<button type="submit" form="contact-form" data-variant="primary">Save</button>'
       assert_includes body, '<input type="text" name="first" value="Ada" ' \
                             ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
+      assert_includes body, '<span>Middle</span><input type="text" name="middle">'
       assert_includes body, '<input type="text" name="last" value="Lovelace" ' \
                             ':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
       assert_includes body,
@@ -940,6 +968,19 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The middle box prefills from N's additional component, unescaped
+  # like every other reading the form starts from.
+  def test_the_edit_screen_prefills_the_middle_name
+    byron = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron\\, the poet;;")
+
+    with_contacts({"ada" => byron}) do
+      get "/contacts/ada/edit"
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, '<input type="text" name="middle" value="Byron, the poet">'
+    end
+  end
+
   # A family name alone is a name (`N:Prince;;;;`): the blank first box
   # is not required over it, or the browser would refuse every save.
   def test_a_family_name_alone_is_a_name_the_editor_can_save
@@ -1109,7 +1150,7 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # FN is the card's own display name, not a rendering of N: a save
-  # that left both name fields alone must not rebuild it from them,
+  # that left every name field alone must not rebuild it from them,
   # or a card spelling the name in full loses every part the form has
   # no field for — the parts N itself keeps.
   def test_a_save_that_moves_no_name_field_keeps_the_cards_own_display_name
@@ -1118,7 +1159,8 @@ class AdminContactsPagesTest < Minitest::Test
 
     with_contacts({"ada" => formal}) do |store|
       digest = store.contact("ada").phones.first.line.digest
-      post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
+      post "/contacts/ada", first: "Ada", middle: "B.", last: "Lovelace",
+                               note: "Countess of Lovelace.",
                                etag: store.contact("ada").etag, phone: {digest => "+1-555-0199"}
 
       assert_equal 303, last_response.status
@@ -1130,7 +1172,7 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # A moved name field is the one time the old display name is stale,
-  # and then FN is the form's reading — N's other three components
+  # and then FN is the form's reading — N's prefixes and suffixes
   # staying where they were, this being an edit of the name and not of
   # the card's spelling of it.
   def test_a_moved_name_field_rewrites_the_display_name
@@ -1138,15 +1180,47 @@ class AdminContactsPagesTest < Minitest::Test
                 .sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;B.;Dr.;Jr.")
 
     with_contacts({"ada" => formal}) do |store|
-      post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+      post "/contacts/ada", first: "Ada", middle: "B.", last: "King", etag: store.contact("ada").etag
 
       assert_equal 303, last_response.status
       card = store.contact("ada").vcard.to_s
-      assert_includes card, "FN:Ada King\r\n"
+      assert_includes card, "FN:Ada B. King\r\n"
       assert_includes card, "N:King;Ada;B.;Dr.;Jr.\r\n"
     end
   end
 
+  # The middle box is a name field like the two beside it: moving it
+  # alone is what makes the old display name stale.
+  def test_a_moved_middle_name_rewrites_the_display_name
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", middle: "Byron", last: "Lovelace",
+                               etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "N:Lovelace;Ada;Byron;;\r\n"
+      assert_includes card, "FN:Ada Byron Lovelace\r\n"
+    end
+  end
+
+  # Blank equals absent in the name row too: emptying the middle box
+  # clears N's additional component and drops it from the display
+  # name. The line stays — N is mandatory (RFC 2426 section 4) and a
+  # component is not a property.
+  def test_a_blank_middle_name_clears_the_additional_component
+    byron = ADA.sub("FN:Ada Lovelace", "FN:Ada Byron Lovelace")
+               .sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron;;")
+
+    with_contacts({"ada" => byron}) do |store|
+      post "/contacts/ada", first: "Ada", middle: "", last: "Lovelace", etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "N:Lovelace;Ada;;;\r\n"
+      assert_includes card, "FN:Ada Lovelace\r\n"
+    end
+  end
+
   # The mandatory property is filled in rather than left absent (RFC
   # 2426 section 4): a card with no FN has no display name to keep, so
   # the untouched-name rule does not apply to it.
@@ -1176,20 +1250,37 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # N's unedited components — additional, prefixes, suffixes — splice
-  # through byte for byte, the still-escaped value never rounding
-  # through unescape and re-escape. The escaped semicolon inside the
-  # additional component is the one an unescaped split would break on;
+  # N's unedited components — the prefixes and suffixes no box shows —
+  # splice through byte for byte, the still-escaped value never
+  # rounding through unescape and re-escape. The escaped semicolon
+  # inside the prefixes is the one an unescaped split would break on;
   # the seed's four components leave the fifth to the padding rule.
   def test_the_names_unedited_components_are_preserved_byte_for_byte
-    honorific = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron\\; Countess;Countess of Lovelace")
+    honorific = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;;Countess\\; Dr.")
 
     with_contacts({"ada" => honorific}) do |store|
-      post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+      post "/contacts/ada", first: "Ada", middle: "", last: "King", etag: store.contact("ada").etag
 
       assert_equal 303, last_response.status
-      assert_includes store.contact("ada").vcard.to_s,
-        "N:King;Ada;Byron\\; Countess;Countess of Lovelace;\r\n"
+      assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;;Countess\\; Dr.;\r\n"
+    end
+  end
+
+  # A name box that submits what it was rendered from keeps its
+  # 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
+  # (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;;")
+
+    with_contacts({"ada" => odd}) do |store|
+      post "/contacts/ada", first: "Ada", middle: "Byron\\x", last: "King",
+                               etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;Byron\\x;;\r\n"
     end
   end