admin: edit a contact's name, nickname, and note
The save is addressed operations over the stored card (VCard#replace
through Store#rewrite), so a property the form has no field for is
never mentioned and cannot be lost — the spec the plan doc builds
everything around. The create route grew `r.is` because a bare verb
block matches any remaining path in Roda, and the collection's POST
would otherwise swallow the record's.

Assisted-by: GLM-5.3 via pi
change yooquvukxzslmopqvvvsvvokzqnsykqw
commit 913bde021497b4ae6ef11a417fbe7d6ee70946f2
author Alpha Chen <alpha@kejadlen.dev>
date
parent oztpqvwl
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
new file mode 100644
index 0000000..570e2cc
--- /dev/null
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -0,0 +1,80 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+
+module ProTacts
+  module Admin
+    # GET /contacts/:id/edit — the editor
+    # (docs/plans/2026-09-05-web-card-editor.md): an explicit mode
+    # rather than an always-editable page, one form over the
+    # properties the save knows how to address. This stage carries the
+    # cardinality-1 set — name, nickname, note — whose save is
+    # VCard#replace under each field; phones, emails, and addresses
+    # arrive with their own stages, birthday last.
+    #
+    # 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. The etag rides
+    # along hidden — the snapshot guard's half, the POST's refusal
+    # being the other. `autofocus` on the first field: this screen's
+    # entry point is the name, not the header search.
+    class ContactsEdit < Phlex::HTML
+      # @rbs @contact: Contact
+      # @rbs @notice: String?
+      # @rbs @first: String?
+      # @rbs @last: String?
+
+      #: (contact: Contact, ?notice: String?) -> void
+      def initialize(contact:, notice: nil)
+        @contact = contact
+        @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 (Web#n_line), never rendered here.
+        family, given = contact.name_components || []
+        @first = given
+        @last = family
+      end
+
+      def view_template
+        render Layout.new(title: "Edit #{@contact.name || @contact.id}", notice: @notice) do
+          a(href: "/contacts/#{@contact.id}", class: "type-label") {
+            "‹ #{@contact.name || @contact.id}"
+          }
+          div(class: "card") do
+            div(class: "card-body") do
+              form(action: "/contacts/#{@contact.id}", method: "post", class: "field-stack") do
+                input(type: "hidden", name: "etag", value: @contact.etag)
+                label(class: "field") do
+                  plain "First"
+                  input(type: "text", name: "first", value: @first,
+                        required: true, autofocus: true)
+                end
+                label(class: "field") do
+                  plain "Last"
+                  input(type: "text", name: "last", value: @last)
+                end
+                label(class: "field") do
+                  plain "Nickname"
+                  input(type: "text", name: "nickname", value: @contact.nickname)
+                end
+                label(class: "field") do
+                  plain "Note"
+                  textarea(name: "note", rows: 4) { @contact.notes.to_s }
+                end
+                # Save is the form's submit; Cancel is navigation — a
+                # link in Gloss's `.btn` contract, which is what an
+                # anchor that acts like a button opts into.
+                div(class: "form-actions") do
+                  button(type: "submit", data: {variant: "primary"}) { "Save" }
+                  a(href: "/contacts/#{@contact.id}", class: "btn") { "Cancel" }
+                end
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index daa400a..ae31606 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -45,6 +45,13 @@ module ProTacts
                   if @contact.nickname
                     div(class: "type-h3 gl-muted", style: "margin-top: var(--gl-space-2xs);") { @contact.nickname }
                   end
+                  # The record's one action, quiet and under the name
+                  # — the edit screen is this page's other mode (see
+                  # Admin::ContactsEdit). A link in Gloss's `.btn`
+                  # contract: navigation wearing a button's clothes.
+                  div(style: "margin-top: var(--gl-space-2xs);") do
+                    a(href: "/contacts/#{@contact.id}/edit", class: "btn", data_size: "sm") { "edit" }
+                  end
                 end
                 # Only a picture earns this slot: an initials circle
                 # beside the name in type-h2 would repeat what the name
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index ebcbbff..cddc905 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -10,6 +10,7 @@ require "nokogiri"
 require "roda"
 
 require "pro_tacts/admin/contact_dialog"
+require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
 require "pro_tacts/debug_logger"
@@ -101,20 +102,48 @@ module ProTacts
         # browser cannot produce one (the dialog's first field is
         # required), so this is the backstop, and a popover cannot be
         # declared open in markup — the toast is the refusal the
-        # re-rendered page can actually show.
-        r.post do
-          first = r.params["first"].to_s.strip
-          last = r.params["last"].to_s.strip
-          if first.empty? && last.empty?
-            dashboard(query: r.params["q"], notice: "A contact needs a name.")
-          else
-            id = SecureRandom.uuid
-            store.put(id, new_contact_card(id, first, last))
-            r.redirect "/contacts/#{id}", 303
+        # re-rendered page can actually show. `r.is` because a bare
+        # verb block matches any remaining path in Roda — without it,
+        # the collection's create would swallow the record's apply,
+        # POST /contacts/:id below.
+        r.is do
+          r.post do
+            first = r.params["first"].to_s.strip
+            last = r.params["last"].to_s.strip
+            if first.empty? && last.empty?
+              dashboard(query: r.params["q"], notice: "A contact needs a name.")
+            else
+              id = SecureRandom.uuid
+              store.put(id, new_contact_card(id, first, last))
+              r.redirect "/contacts/#{id}", 303
+            end
           end
         end
 
         r.on String do |id|
+          # The browser's edit of one contact
+          # (docs/plans/2026-09-05-web-card-editor.md): an explicit
+          # mode — GET renders the form, POST applies it, success is a
+          # 303 back to the details page so the back button cannot
+          # double-submit. POST stays the wire verb, the create's
+          # precedent: HTML forms speak only GET and POST, and the
+          # admin surface's one client is the form.
+          r.get "edit" do
+            contact = store.contact(id)
+
+            # No match falls through to the empty-body 404 the
+            # not_found handler fills in, same as the page below.
+            if contact
+              response["Content-Type"] = "text/html; charset=utf-8"
+              Admin::ContactsEdit.call(contact:)
+            end
+          end
+
+          # The edit's apply, under the same fall-through-to-404 rule.
+          r.post do
+            apply_edit(r, id)
+          end
+
           # The picture the avatars render (Admin::Avatar): decoded
           # bytes under their own content type, served from a route
           # rather than inlined as base64 so a page of avatars is a
@@ -600,6 +629,47 @@ module ProTacts
       nil
     end
 
+    # The whole of the edit POST, a private method for the same reason
+    # write_card is one: a Roda route block cannot return early, so each
+    # refusal is a value the block ends with rather than a branch it
+    # exits. The checks run in write_card's order — the request's own
+    # validity first, the conditionals on stored state after.
+    #: (untyped r, String id) -> String?
+    def apply_edit(r, id)
+      # No match is nil, which falls through to the empty-body 404 the
+      # not_found handler fills in — the GET handler's rule.
+      contact = store.contact(id)
+      return if contact.nil?
+
+      # N and FN are mandatory (RFC 2426 section 4), so a save blank
+      # throughout is refused — the toast is the backstop, the form's
+      # required field being the browser's own refusal of the same.
+      first = r.params["first"].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?
+
+      # The snapshot guard, the lost-update half If-Match gives DAV
+      # clients (RFC 7232 section 3.1): the form carried the etag of
+      # the card it was rendered from, and a contact that hashes
+      # differently now was edited in between — another tab, or a
+      # client sync — so applying this save over that one would revert
+      # it. The refusal re-renders from the current card, so the screen
+      # shows what changed; the check shares write_card's millisecond
+      # race window between check and write, noted there.
+      return edit_screen(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
+
+      store.rewrite(id, edited_card(contact, first, last, r.params).to_s)
+      r.redirect "/contacts/#{id}", 303
+    end
+
+    # The re-render a refused save answers with: the edit screen again,
+    # from the current card, with the refusal as its toast.
+    #: (Contact contact, ?notice: String) -> String
+    def edit_screen(contact, notice: nil)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      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
@@ -617,6 +687,57 @@ module ProTacts
         "END:VCARD\r\n"
     end
 
+    # The surgical save (docs/plans/2026-09-05-web-card-editor.md):
+    # each field names the property it replaces on the stored card's
+    # own bytes, and a property the form has no field for is never
+    # mentioned — what is never mentioned cannot be lost. Blank equals
+    # absent throughout, the reader's own rule (Contact#text_of) in the
+    # other direction: a blank nickname or note removes the property,
+    # and a blank name was already refused above. REV is not touched —
+    # the change log is the record of when, and macOS re-stamps REV on
+    # its own next rewrite.
+    #: (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
+
+      card = contact.stored
+      card = card.replace("N", [n_line(card, first, last)])
+      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))
+      card
+    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
+
+    # N's replacement line: a splice over the still-escaped value
+    # (VCard.split_raw_components), not an unescape-then-re-escape round
+    # trip, which is not byte-stable. The first two components are the
+    # form's; the remaining three — additional, prefixes, suffixes (RFC
+    # 2426 section 3.1.2) — rejoin byte for byte. A card with no N a
+    # form can read (none, or a line that would not) splices into a
+    # bare five-component value; one short of five is padded, the same
+    # empties a whole-N writer would leave.
+    #: (VCard card, String first, String last) -> String
+    def n_line(card, first, last)
+      property = card.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/public/admin.css b/public/admin.css
index 488c66f..7248b89 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -4,6 +4,39 @@
    rem width in the spirit of card.css's own max-width: 18rem; nothing
    here is a hex, a px, or a duration. */
 
+/* The editor's submit row: the buttons sit beside each other under
+   the stacked fields, rather than in the stack's own gap. */
+.form-actions {
+  display: flex;
+  gap: var(--gl-space-2xs);
+}
+
+/* Gloss's field contract styles input and select but not textarea
+   (base.css only resets it), and the editor's note field is one —
+   multiline text under a .field caption. The box mirrors field.css's
+   input rule in the vars that rule itself uses, so the two controls
+   read as one component; resize stays vertical, the one freedom a
+   text area has that a text input does not. */
+textarea {
+  width: 100%;
+  padding: var(--gl-space-xs) var(--gl-space-s);
+  background: var(--gl-color-surface-card);
+  border: 1px solid var(--gl-color-border-default);
+  border-radius: var(--gl-radius-sm);
+  color: var(--gl-color-text-primary);
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--1);
+  text-transform: none;
+  letter-spacing: normal;
+  font-weight: 400;
+  outline: none;
+  resize: vertical;
+}
+textarea:focus {
+  border-color: var(--gl-color-accent);
+  box-shadow: 0 0 0 2px var(--gl-color-accent-soft);
+}
+
 /* A chrome bar, not a content gutter: the block padding is the
    scale's second-smallest step, so the header hugs its one line of
    content instead of spacing it like page copy. Inline padding stays
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 6e9a762..62ee2f2 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -10,6 +10,10 @@ module ProTacts
       def self.call: (contact: Contact) -> String
     end
 
+    class ContactsEdit
+      def self.call: (contact: Contact, ?notice: String?) -> String
+    end
+
     class ContactsIndex
       def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> String
     end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7876d61..0966835 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -573,4 +573,186 @@ class AdminContactsPagesTest < Minitest::Test
       assert_empty store.contacts
     end
   end
+
+  ## Editing contacts
+
+  # The edit screen: one form prefilled from the card — N's two
+  # leading components as the name fields, the accessors' unescaped
+  # readings as the rest — carrying the etag of the card it renders
+  # from, the snapshot guard's hidden half.
+  def test_the_edit_screen_prefills_the_cards_values
+    red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
+
+    with_contacts({"red" => red}) do |store|
+      get "/contacts/red/edit"
+
+      assert_equal 200, last_response.status
+      body = last_response.body
+      assert_includes body, '<form action="/contacts/red" method="post" class="field-stack">'
+      assert_includes body, '<input type="text" name="first" value="Ada" required autofocus>'
+      assert_includes body, '<input type="text" name="last" value="Lovelace">'
+      assert_includes body, '<input type="text" name="nickname" value="Red">'
+      assert_includes body, "<textarea name=\"note\" rows=\"4\">Countess of Lovelace.</textarea>"
+      # The etag carries its quotes, HTML-escaped in the attribute.
+      assert_includes body, %(<input type="hidden" name="etag" value="&quot;#{store.contact("red").etag.delete('"')}&quot;">)
+    end
+  end
+
+  def test_the_edit_screen_of_an_unknown_contact_is_404
+    with_contacts({}) { get "/contacts/nope/edit" }
+
+    assert_equal 404, last_response.status
+  end
+
+  # The details page names its other mode: the edit link sits under
+  # the name, the record's one action.
+  def test_the_details_page_links_to_the_edit_screen
+    with_contacts({"ada" => ADA}) do
+      get "/contacts/ada"
+
+      assert_includes last_response.body, 'href="/contacts/ada/edit"'
+    end
+  end
+
+  # The save is addressed operations, and this is the whole design:
+  # the four properties the form names are replaced, every other line
+  # of the stored card is byte-identical, and the write lands through
+  # Store#rewrite with its change-log entry.
+  def test_saving_the_edit_replaces_only_the_addressed_properties
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "King", nickname: "Countess",
+                               note: "First programmer.", etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      assert_equal "/contacts/ada", last_response["Location"]
+
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "N:King;Ada;;;"
+      assert_includes card, "FN:Ada King"
+      assert_includes card, "NICKNAME:Countess"
+      assert_includes card, "NOTE:First programmer."
+      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"
+      assert_includes card, "UID:ada\r\n"
+      # The birthday is model state the edit never touched, composed
+      # back into the served card.
+      assert_includes card, "BDAY:1985-12-10\r\n"
+      assert(store.changes.any? { it.action == "edit" && it.card_id == "ada" })
+    end
+  end
+
+  # Blank equals absent on the way back: a blank nickname or note
+  # removes the property, as a blank value is absent to the reader
+  # (Contact#text_of) — and filling one in adds it where the card
+  # carried none.
+  def test_a_blank_nickname_or_note_removes_the_property
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "Lovelace", nickname: "", note: "", etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      refute_includes card, "NICKNAME"
+      refute_includes card, "NOTE"
+    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;
+  # 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")
+
+    with_contacts({"ada" => honorific}) do |store|
+      post "/contacts/ada", first: "Ada", 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"
+    end
+  end
+
+  # A card whose N stops short of five components is padded with the
+  # same empties a whole-N writer would leave.
+  def test_a_short_n_is_padded_to_five_components
+    short = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada")
+
+    with_contacts({"ada" => short}) do |store|
+      post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;;;\r\n"
+    end
+  end
+
+  # Values are text and the card is structure: punctuation escapes in
+  # the written lines, and a note's line breaks travel as the escape
+  # rather than ending the property's line.
+  def test_punctuation_and_line_breaks_escape_in_the_written_lines
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "King",
+                               nickname: "Countess, of mathematics", note: "line one\nline two",
+                               etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "NICKNAME:Countess\\, of mathematics\r\n"
+      assert_includes card, "NOTE:line one\\nline two\r\n"
+
+      follow_redirect!
+      assert_includes last_response.body, "Countess, of mathematics"
+    end
+  end
+
+  # A fold on a line the save never mentioned is the byte-identity
+  # claim at its sharpest: the continuation survives with its own
+  # bytes, not normalized into the unfolded reading.
+  def test_a_folded_line_the_save_never_mentioned_is_left_alone
+    folded = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n", "TEL;TYPE=mobile:+1-555-\r\n 0100\r\n")
+
+    with_contacts({"ada" => folded}) do |store|
+      post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+
+      assert_equal 303, last_response.status
+      assert_includes store.contact("ada").vcard.to_s, "TEL;TYPE=mobile:+1-555-\r\n 0100\r\n"
+    end
+  end
+
+  # The snapshot guard: a save whose etag names a card the store no
+  # longer holds is refused with a toast, and nothing is written —
+  # another tab or a client sync edited the contact in between, and
+  # the save would have reverted it.
+  def test_a_save_over_a_stale_snapshot_is_refused
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: "Ada", last: "King", etag: '"stale"'
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body,
+        '<div role="status" data-fixed><span>This contact changed since the page loaded; nothing was saved.</span></div>'
+      # The refusal re-renders the screen from the current card.
+      assert_includes last_response.body, 'value="Ada"'
+      assert_includes store.contact("ada").vcard.to_s, "FN:Ada Lovelace"
+    end
+  end
+
+  # The backstop for the one request no browser can send (the first
+  # field is required): a refused save, nothing written — N and FN are
+  # mandatory, so a name blank throughout cannot be saved.
+  def test_a_nameless_edit_is_refused_with_a_toast
+    with_contacts({"ada" => ADA}) do |store|
+      post "/contacts/ada", first: " ", last: "", etag: store.contact("ada").etag
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, '<div role="status" data-fixed><span>A contact needs a name.</span></div>'
+      assert_includes store.contact("ada").vcard.to_s, "FN:Ada Lovelace"
+    end
+  end
+
+  def test_saving_an_unknown_contact_is_404
+    with_contacts({}) { post "/contacts/nope", first: "No", etag: '"x"' }
+
+    assert_equal 404, last_response.status
+  end
 end