admin: read the edit forms back in Admin::CardForm
The splice that reads a form back into its card leaves web.rb to sit beside the views;
the Steepfile now names each view it skips, so this file stays checked, which a bare directory would refuse.

Assisted-by: Claude Opus 5 via Claude Code
change zxpslrukptzwtpupnnvwqxpsoxywqoxp
commit 5fb1a20a8945cdcdef399bc57c086d7f57a67030
author Alpha Chen <alpha@kejadlen.dev>
date
parent olwvtvzw
diff --git a/Steepfile b/Steepfile
index 8c4ad9e..f062c0e 100644
--- a/Steepfile
+++ b/Steepfile
@@ -8,17 +8,22 @@
 # (see sig/pro_tacts/). Both are documented where they are defined.
 target :lib do
   check "lib", inline: true
-  # Phlex ships no RBS of its own, and lib/pro_tacts/admin is built on
-  # it — unlike Sequel, Roda, Nokogiri, and friends, which have hand
-  # written stand-ins under sig/gems. Ignored rather than stubbed for
-  # now; add sig/gems/phlex.rbs and drop this once the admin surface is
-  # worth typing.
+  # Phlex ships no RBS of its own, and the admin views are built on it —
+  # unlike Sequel, Roda, Nokogiri, and friends, which have hand written
+  # stand-ins under sig/gems. Ignored rather than stubbed for now; add
+  # sig/gems/phlex.rbs and drop this once the admin surface is worth
+  # typing. Named file by file so that what lives beside the views
+  # without being one — card_form.rb, the forms read back into cards —
+  # stays checked; a new file there is checked until it is listed.
   #
   # inline: true is load-bearing: `ignore` keeps separate lists for
   # plain and inline sources, and these files were enrolled by
   # `check ... inline: true`. A bare `ignore` feeds the other list and
   # the files stay checked.
-  ignore "lib/pro_tacts/admin", inline: true
+  ignore(*%w[
+    avatar contact_dialog contacts_edit contacts_index contacts_show device_setup format
+    group_label groups_edit groups_index groups_show icons layout phlex upcoming_birthdays
+  ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
   library "date", "digest", "fileutils", "json", "logger", "pathname", "securerandom", "strscan", "yaml"
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
new file mode 100644
index 0000000..1568afb
--- /dev/null
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -0,0 +1,319 @@
+require "pro_tacts/birthday"
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+  module Admin
+    # The admin editors' forms read back into cards: the fields
+    # ContactsEdit and GroupsEdit render, spliced into the card each was
+    # rendered from, and the create dialog's two names made into a new
+    # one. The surgical save — the hazard it exists to remove, the
+    # blank-equals-absent rule, and why REV is left alone — is
+    # docs/plans/2026-09-05-web-card-editor.md.
+    #
+    # Not a view, unlike its neighbours, so the Steepfile checks it.
+    module CardForm
+      # 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
+
+      # 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
+      # Contact). Nothing else: a created card has no prior octets to
+      # preserve, and every other property arrives by editing the
+      # 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)
+        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" \
+            "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)
+        nickname = params["nickname"].to_s.strip
+        note = params["note"].to_s.strip
+
+        # The rows are read off the contact's own card, the one the form
+        # rendered (Admin::ContactsEdit) and the one this save splices:
+        # a line a group lends is on neither, so no digest here can name
+        # one and no save can copy a group's value onto its member
+        # (Contact#own).
+        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("NICKNAME", text_lines("NICKNAME", nickname))
+        vcard = vcard.replace("NOTE", text_lines("NOTE", note))
+        vcard = edited_phones(own, vcard, params)
+        vcard = edited_emails(own, vcard, params)
+        edited_addresses(own, vcard, params)
+      end
+
+      # The group editor's save: the note replaced and the contact
+      # editor's own address splice over the group's lines read as a
+      # card (Store::Group#reading), then back into the lines a group
+      # holds — each unfolded and shorn of its terminator, the unit
+      # CardDiff records a write in and group_properties stores.
+      #: (Store::Group group, Hash[String, untyped] params) -> Array[String]
+      def self.group_lines(group, params)
+        reading = group.reading
+        card = reading.stored.replace("NOTE", text_lines("NOTE", params["note"].to_s.strip))
+        card = edited_addresses(reading, card, params)
+        card.lines.filter_map {
+          line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+          line unless line.empty?
+        }
+      end
+
+      # The birthday row's parse: each blank component is an absence
+      # and three blanks are no birthday, so Birthday's constructor is
+      # the whole grammar — six shapes, ranges standing where the
+      # components stand — and the digit check before it keeps to_i
+      # honest (a "19x5" is not the year 19). All three blank returns
+      # before the constructor, the grammar refusing nothing-at-all;
+      # raises ArgumentError otherwise, the constructor's own refusal
+      # class, for the save to catch and re-render — nothing here
+      # reaches Sentry, bad input being ordinary and the toast the
+      # fallback.
+      #: (untyped fields) -> Birthday?
+      def self.birthday(fields)
+        year = birthday_component(fields["year"])
+        month = birthday_component(fields["month"])
+        day = birthday_component(fields["day"])
+        return if year.nil? && month.nil? && day.nil?
+
+        Birthday.new(year:, month:, day:)
+      end
+
+      # The phones' half of the surgical save: 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 addresses come from the
+      # contact, never the request, so a doctored digest names nothing
+      # and a missing one touches nothing.
+      #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+      def self.edited_phones(contact, vcard, params)
+        rows = params["phone"]
+        if rows.is_a?(Hash)
+          contact.phones.each do |phone|
+            # The property is nil only for a line that would not read,
+            # and phones read from lines that did; the guard is the
+            # type's honesty, not a reachable case (n_line's own shape).
+            property = phone.line.property
+            next if property.nil?
+
+            submitted = rows[phone.line.digest]
+            next if submitted.nil? || submitted.to_s.strip == phone.value
+
+            value = submitted.to_s.strip
+            vcard = vcard.substitute(
+              phone.line.digest,
+              value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
+            )
+          end
+        end
+
+        # The rows the add dialog reveals (Admin::ContactsEdit): each
+        # value lands as a bare TEL 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()
+        # because the field is a list and a request carrying one value
+        # is still a list of one.
+        submitted = Array(params["new_phone"]) #: Array[untyped]
+        added = submitted.filter_map {
+          value = it.to_s.strip
+          "TEL:#{VCard.escape(value)}\r\n" unless value.empty?
+        } #: Array[String]
+        vcard.insert(added)
+      end
+
+      # #edited_phones' walk, over EMAIL.
+      #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+      def self.edited_emails(contact, vcard, 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
+            vcard = vcard.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 {
+          value = it.to_s.strip
+          "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
+        } #: Array[String]
+        vcard.insert(added)
+      end
+
+      # #edited_phones' 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 = params["address"]
+        if rows.is_a?(Hash)
+          contact.addresses.each do |address|
+            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)
+            vcard = vcard.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]
+        vcard.insert(added.filter_map { address_line(nil, it) })
+      end
+
+      #: (Contact::Address address, untyped submitted) -> bool
+      def self.address_unchanged?(address, submitted)
+        ADDRESS_COMPONENTS.keys.all? { |name|
+          submitted[name].to_s.strip == address.public_send(name).to_s
+        }
+      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 self.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
+
+      # 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.
+      #: (untyped submitted) -> Integer?
+      def self.birthday_component(submitted)
+        value = submitted.to_s.strip
+        return nil if value.empty?
+        raise ArgumentError, "not a number a birthday holds: #{value.inspect}" unless value.match?(/\A\d{1,4}\z/)
+
+        value.to_i
+      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.
+      #: (String name, String value) -> Array[String]
+      def self.text_lines(name, value)
+        return [] if value.empty?
+
+        ["#{name}:#{VCard.escape(value)}\r\n"]
+      end
+
+      # FN's replacement, or the card untouched: the display name is the
+      # card's own text (RFC 2426 section 3.1.1), not a rendering of N,
+      # and "Dr. Ada B. Lovelace, Jr." holds parts no field on this form
+      # does — the same parts n_line rejoins byte for byte rather than
+      # dropping. Rebuilding it under a save that only moved a phone
+      # number would lose them, so FN is rewritten only when a name
+      # field moved under it, which is the one time the old display
+      # 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 || []
+        named = vcard.properties.any? { it.name.casecmp?("FN") }
+        return vcard if named && first == given.to_s && last == family.to_s
+
+        vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\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)
+        property = vcard.properties.find { it.name.casecmp?("N") }
+        components = [] #: Array[String]
+        components.replace(VCard.split_raw_components(property.value)) if property
+        components << "" while components.length < 5
+        components[0] = VCard.escape(last)
+        components[1] = VCard.escape(first)
+        "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
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 9315030..ebe7e84 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -19,8 +19,8 @@ module ProTacts
     # (docs/plans/2026-09-07-web-birthday-editor.md).
     #
     # The fields prefill from the accessors' unescaped readings, and
-    # blank equals absent on the way back (Web#edited_card), so write
-    # and read agree on what an empty value means. Blank's meaning is
+    # blank equals absent on the way back (CardForm.contact_card), so
+    # write and read agree on what an empty value means. Blank's meaning is
     # stated before the save, not only enforced by it: a standing row
     # whose blanking deletes — a phone, email, or address line, the
     # nickname or note property, a held birthday — wears
@@ -96,7 +96,7 @@ module ProTacts
         # 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 (Web#n_line), never rendered here.
+        # raw splice (CardForm.n_line), never rendered here.
         family, given = contact.name_components || []
         @first = given
         @last = family
@@ -189,7 +189,8 @@ module ProTacts
                   birthday_row
                   added_rows
                   # The first NOTE the card carries: this row edits one,
-                  # and a save writes one (Web#edited_card's replace).
+                  # and a save writes one (CardForm.contact_card's
+                  # replace).
                   note = @own.notes.first&.value
                   label(class: "field", data: {blank_removes: !!note}) do
                     span { "Note" }
@@ -221,7 +222,7 @@ module ProTacts
       # scaffold docs/DESIGN.md refuses, and a trailing one left over
       # from a row already filled is the same scaffold arriving late.
       # A blank added row is a no-op rather than a removal
-      # (Web#edited_phones), so these rows wear no removal state —
+      # (CardForm.edited_phones), so these rows wear no removal state —
       # the class comment's exemption.
       # That verb — make a row, now, without a round trip — is what
       # CSS could not do and what Alpine is here for (see Layout).
@@ -348,8 +349,8 @@ module ProTacts
       # 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 (Web#address_line), so a
-      # line surviving by its po box alone cannot be removed from
+      # blank throughout, po box included (CardForm.address_line), 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
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 0d84ffb..b11c4b5 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -10,6 +10,7 @@ require "rack/rewindable_input"
 require "nokogiri"
 require "roda"
 
+require "pro_tacts/admin/card_form"
 require "pro_tacts/admin/contact_dialog"
 require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
@@ -42,21 +43,6 @@ 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
@@ -143,7 +129,7 @@ module ProTacts
               dashboard(query: r.params["q"], notice: "A contact needs a name.")
             else
               id = SecureRandom.uuid
-              store.put(id, new_contact_card(id, first, last))
+              store.put(id, Admin::CardForm.new_card(id, first, last))
               r.redirect "/contacts/#{id}", 303
             end
           end
@@ -857,7 +843,7 @@ module ProTacts
       birthday =
         if (fields = r.params["birthday"]).is_a?(Hash)
           begin
-            parse_birthday(fields)
+            Admin::CardForm.birthday(fields)
           rescue ArgumentError
             return edit_screen(contact, notice: "That birthday is not a shape a date can take.")
           end
@@ -883,14 +869,13 @@ module ProTacts
         return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
       end
 
-      store.rewrite(id, edited_card(contact, first, last, r.params), birthday:)
+      store.rewrite(id, Admin::CardForm.contact_card(contact, first, last, r.params), birthday:)
       r.redirect "/contacts/#{id}", 303
     end
 
     # The group editor's POST, #apply_edit's shape over a group: the
-    # snapshot guard, then the contact editor's own address splice and
-    # note replace over the group's lines read as a card (Store::Group#reading),
-    # and one store write for the lot.
+    # snapshot guard, then the lines the form splices
+    # (Admin::CardForm.group_lines), and one store write for the lot.
     #: (untyped r, String id) -> String?
     def apply_group_edit(r, id)
       group = store.group(id)
@@ -900,9 +885,6 @@ module ProTacts
         return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
       end
 
-      reading = group.reading
-      card = reading.stored.replace("NOTE", text_lines("NOTE", r.params["note"].to_s.strip))
-      card = edited_addresses(reading, card, r.params)
       # Only ids that name a card: a membership row is a foreign key,
       # and a doctored id is ordinary bad input rather than a 500. A
       # POST carrying no list keeps the membership it found, the
@@ -916,7 +898,7 @@ module ProTacts
         end #: Array[String]
 
       begin
-        store.edit_group(id, name: r.params["name"].to_s, lines: group_lines(card), members:)
+        store.edit_group(id, name: r.params["name"].to_s, lines: Admin::CardForm.group_lines(group, r.params), members:)
       rescue Sequel::UniqueConstraintViolation
         # A taken `sync:` name (db/migrations/007_sync_names.rb). The save
         # is one transaction, so nothing of it landed.
@@ -925,17 +907,6 @@ module ProTacts
       r.redirect "/groups/#{id}", 303
     end
 
-    # The card a group save spliced, back into the lines a group holds:
-    # each unfolded and shorn of its terminator, the unit CardDiff
-    # records a write in and group_properties stores.
-    #: (VCard card) -> Array[String]
-    def group_lines(card)
-      card.lines.filter_map {
-        line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
-        line unless line.empty?
-      }
-    end
-
     #: (Store::Group group, ?notice: String) -> String
     def group_edit_screen(group, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
@@ -954,276 +925,6 @@ module ProTacts
       Admin::ContactsEdit.call(contact:, notice:)
     end
 
-    # 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
-    # Contact). Nothing else: a created card has no prior octets to
-    # preserve, and every other property arrives by editing the
-    # 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 new_contact_card(id, first, 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" \
-          "UID:#{id}\r\n" \
-          "END:VCARD\r\n",
-      )
-    end
-
-    # The surgical save — the hazard it exists to remove, the
-    # blank-equals-absent rule, and why REV is left alone are
-    # docs/plans/2026-09-05-web-card-editor.md.
-    #: (Contact contact, String first, String last, Hash[String, untyped] params) -> VCard
-    def edited_card(contact, first, last, params)
-      nickname = params["nickname"].to_s.strip
-      note = params["note"].to_s.strip
-
-      # The rows are read off the contact's own card, the one the form
-      # rendered (Admin::ContactsEdit) and the one this save splices:
-      # a line a group lends is on neither, so no digest here can name
-      # one and no save can copy a group's value onto its member
-      # (Contact#own).
-      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("NICKNAME", text_lines("NICKNAME", nickname))
-      vcard = vcard.replace("NOTE", text_lines("NOTE", note))
-      vcard = edited_phones(own, vcard, params)
-      vcard = edited_emails(own, vcard, params)
-      edited_addresses(own, vcard, params)
-    end
-
-    # The phones' half of the surgical save: 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 addresses come from the
-    # contact, never the request, so a doctored digest names nothing
-    # and a missing one touches nothing.
-    #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
-    def edited_phones(contact, vcard, params)
-      rows = params["phone"]
-      if rows.is_a?(Hash)
-        contact.phones.each do |phone|
-          # The property is nil only for a line that would not read,
-          # and phones read from lines that did; the guard is the
-          # type's honesty, not a reachable case (n_line's own shape).
-          property = phone.line.property
-          next if property.nil?
-
-          submitted = rows[phone.line.digest]
-          next if submitted.nil? || submitted.to_s.strip == phone.value
-
-          value = submitted.to_s.strip
-          vcard = vcard.substitute(
-            phone.line.digest,
-            value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
-          )
-        end
-      end
-
-      # The rows the add dialog reveals (Admin::ContactsEdit): each
-      # value lands as a bare TEL 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()
-      # because the field is a list and a request carrying one value
-      # is still a list of one.
-      submitted = Array(params["new_phone"]) #: Array[untyped]
-      added = submitted.filter_map {
-        value = it.to_s.strip
-        "TEL:#{VCard.escape(value)}\r\n" unless value.empty?
-      } #: Array[String]
-      vcard.insert(added)
-    end
-
-    # #edited_phones' walk, over EMAIL.
-    #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
-    def edited_emails(contact, vcard, 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
-          vcard = vcard.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 {
-        value = it.to_s.strip
-        "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
-      } #: Array[String]
-      vcard.insert(added)
-    end
-
-    # #edited_phones' 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 edited_addresses(contact, vcard, params)
-      rows = params["address"]
-      if rows.is_a?(Hash)
-        contact.addresses.each do |address|
-          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)
-          vcard = vcard.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]
-      vcard.insert(added.filter_map { address_line(nil, it) })
-    end
-
-    #: (Contact::Address address, untyped submitted) -> bool
-    def address_unchanged?(address, submitted)
-      ADDRESS_COMPONENTS.keys.all? { |name|
-        submitted[name].to_s.strip == address.public_send(name).to_s
-      }
-    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
-
-    # The birthday row's parse: each blank component is an absence
-    # and three blanks are no birthday, so Birthday's constructor is
-    # the whole grammar — six shapes, ranges standing where the
-    # components stand — and the digit check before it keeps to_i
-    # honest (a "19x5" is not the year 19). All three blank returns
-    # before the constructor, the grammar refusing nothing-at-all;
-    # raises ArgumentError otherwise, the constructor's own refusal
-    # class, for the save to catch and re-render — nothing here
-    # reaches Sentry, bad input being ordinary and the toast the
-    # fallback.
-    #: (untyped fields) -> Birthday?
-    def parse_birthday(fields)
-      year = birthday_component(fields["year"])
-      month = birthday_component(fields["month"])
-      day = birthday_component(fields["day"])
-      return if year.nil? && month.nil? && day.nil?
-
-      Birthday.new(year:, month:, day:)
-    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.
-    #: (untyped submitted) -> Integer?
-    def birthday_component(submitted)
-      value = submitted.to_s.strip
-      return nil if value.empty?
-      raise ArgumentError, "not a number a birthday holds: #{value.inspect}" unless value.match?(/\A\d{1,4}\z/)
-
-      value.to_i
-    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.
-    #: (String name, String value) -> Array[String]
-    def text_lines(name, value)
-      return [] if value.empty?
-
-      ["#{name}:#{VCard.escape(value)}\r\n"]
-    end
-
-    # FN's replacement, or the card untouched: the display name is the
-    # card's own text (RFC 2426 section 3.1.1), not a rendering of N,
-    # and "Dr. Ada B. Lovelace, Jr." holds parts no field on this form
-    # does — the same parts n_line rejoins byte for byte rather than
-    # dropping. Rebuilding it under a save that only moved a phone
-    # number would lose them, so FN is rewritten only when a name
-    # field moved under it, which is the one time the old display
-    # 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 edited_fn(contact, vcard, first, last)
-      family, given = contact.name_components || []
-      named = vcard.properties.any? { it.name.casecmp?("FN") }
-      return vcard if named && first == given.to_s && last == family.to_s
-
-      vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\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 n_line(vcard, first, last)
-      property = vcard.properties.find { it.name.casecmp?("N") }
-      components = [] #: Array[String]
-      components.replace(VCard.split_raw_components(property.value)) if property
-      components << "" while components.length < 5
-      components[0] = VCard.escape(last)
-      components[1] = VCard.escape(first)
-      "N:#{components.join(";")}\r\n"
-    end
-
     # A card that breaks one of the parser's assumptions is not bad
     # input, it is news that the assumption is wrong and every
     # simplification resting on it is now suspect — an error, where a
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index f90c191..16a6d65 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -880,9 +880,9 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # A po box has no field, and the save's removal is blank throughout,
-  # po box included (Web#address_line) — 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.
+  # po box included (Admin::CardForm.address_line) — 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
     po = ADA.sub(
       "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",