admin: add the birthday from the dialog over an empty row
An always-rendered row was a scaffolded empty attribute — the shape
docs/DESIGN.md refuses, and every other property had already shed it.
The birthday is the dialog's first conditional type, a card holding
at most one: its radio leaves the list once the row stands, and Add
guards the push.

Assisted-by: GLM-5.3 via pi
change tymxrlykoqrqoktnunkrqumvouukxxys
commit ef5befb65bd95e3cf982504438f89920cb134ef7
author Alpha Chen <alpha@kejadlen.dev>
date
parent rvuqnylp
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 3b98550..ff85bde 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -29,17 +29,18 @@ module ProTacts
     # destructive is what that color means there, carried by the
     # caption rather than the boxes' borders, a blank row being a
     # valid save and Gloss's danger border the aria-invalid
-    # contract. The birthday row earns a remove control of its own
-    # over a held birthday, three separate blanks being a rule
-    # nobody can guess: one click empties the row (Alpine's own
-    # verb, a mutation markup cannot do) and the blank rule does the
-    # rest. The state itself is CSS over :placeholder-shown, not
+    # contract. The birthday row renders only over a held birthday —
+    # absence is added by the dialog like any other property — and
+    # earns a remove control of its own, three separate blanks
+    # being a rule nobody can guess: one click empties the row
+    # (Alpine's own verb, a mutation markup cannot do) and the blank
+    # rule does the rest. The state itself is CSS over :placeholder-shown, not
     # Alpine — revealing a state over standing elements is markup's
     # job (see Layout). The rows the add dialog reveals wear none of
-    # it, their blank a no-op rather than a removal; the rows that
-    # render whether or not the property exists wear it only over
-    # something to lose (each such row's comment says which). The
-    # etag rides
+    # it, their blank a no-op rather than a removal; the nickname
+    # and note rows, which render whether or not the card carries
+    # the property, wear it only over something to lose (each row's
+    # own comment says which). 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.
@@ -48,12 +49,15 @@ module ProTacts
       # lists them — exactly the rows the save can insert
       # (docs/plans/2026-09-05-web-card-editor.md), because a type
       # listed here that the save cannot write would be a row that
-      # silently does nothing. Each name is 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]
+      # silently does nothing. Each name is the radio's value;
+      # `new_<type>[]` is the single-value kinds' field name and
+      # `new_address[<i>][<component>]` the structured one's, the
+      # bare [] unable to carry a component set (Rack refuses a key
+      # after an empty one). The birthday is the exception: its
+      # added row names the model's own `birthday[]` group, there
+      # being no card line to insert and room for but one — see
+      # #addable_types for when the dialog offers it.
+      ADDABLE_TYPES = %w[phone email address birthday].freeze #: Array[String]
       private_constant :ADDABLE_TYPES
 
       # The address row's component fields, stacked in the value
@@ -223,7 +227,7 @@ module ProTacts
             # 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
+            template("x-if": "kind !== 'address' && kind !== 'birthday'") do
               input(":type": "kind === 'phone' ? 'tel' : kind", ":name": "`new_${kind}[]`",
                     ":aria-label": "kind", "x-init": "$el.focus()")
             end
@@ -241,6 +245,22 @@ module ProTacts
                 end
               end
             end
+            # The birthday kind: the standing row's own three
+            # controls with nothing prefilled, naming the model's
+            # birthday[] group rather than a new_* name — no card
+            # line exists to insert, the save parses the group into
+            # the model, and no digest is needed when a contact can
+            # hold but one. No removal state, the added rows'
+            # exemption: three blanks over a contact with no
+            # birthday is nothing, not a removal. A second one
+            # cannot arrive — the dialog stops offering the type
+            # once this row stands, and Add's handler guards the
+            # push anyway.
+            template("x-if": "kind === 'birthday'") do
+              div(class: "date-row") do
+                birthday_controls(nil, focus: true)
+              end
+            end
           end
         end
       end
@@ -249,55 +269,68 @@ module ProTacts
       # than the card's bytes (docs/plans/2026-09-07-web-birthday-editor.md)
       # — a partial date has no vCard 3.0 spelling, so there is no
       # line to address and the prefill is the model
-      # (Contact#birthday), not a reading off the card. Three controls
-      # in the value column, month-day-year to match the prose
-      # Birthday#to_s renders; a select for the month because a name
-      # is the friendlier read and constrains the value by
-      # construction, number inputs with native ranges for the day and
-      # year. A blank component is an absence and three blanks remove
-      # the birthday, the form's blank-equals-absent; a day without
-      # its month is refused at the save, the grammar's own rule.
-      # Every shape the grammar admits is editable, including the ones
-      # no client renders — they live here and go nowhere, their
-      # documented fate. The removal state rides only on a held
-      # birthday — the row renders empty without one, and three blanks
-      # over no birthday delete nothing.
+      # (Contact#birthday), not a reading off the card. It renders
+      # only over a held birthday: an empty row was an attribute
+      # rendering nothing (docs/DESIGN.md), and the way a birthday
+      # arrives is the add dialog's, like every other property. A
+      # blank component is an absence and three blanks remove the
+      # birthday, the form's blank-equals-absent; a day without its
+      # month is refused at the save, the grammar's own rule. Every
+      # shape the grammar admits is editable, including the ones no
+      # client renders — they live here and go nowhere, their
+      # documented fate. Every standing row holds a birthday, so
+      # the removal state rides on it unconditionally.
       #: () -> void
       def birthday_row
         birthday = @contact.birthday
-        div(class: "field", data: {blank_removes: !!birthday}) do
+        return unless birthday
+
+        div(class: "field", data: {blank_removes: true}) do
           span { "birthday" }
           div(class: "date-row") do
-            select(name: "birthday[month]", aria_label: "month") do
-              option(value: "", selected: birthday&.month.nil?) { "month" }
-              Date::MONTHNAMES.compact.each_with_index do |name, index|
-                option(value: index + 1, selected: birthday&.month == index + 1) { name }
-              end
-            end
-            input(type: "number", name: "birthday[day]", value: birthday&.day,
-                  min: 1, max: 31, placeholder: "day", aria_label: "day")
-            input(type: "number", name: "birthday[year]", value: birthday&.year,
-                  min: 1, max: 9999, placeholder: "year", aria_label: "year")
-            # The remove control, over a held birthday only: three
-            # blanks is the rule that removes and this is its
-            # one-click spelling — @click empties the row's controls,
-            # the blank rule and the struck caption do the rest, and
-            # retyping the date is the undo. Gloss's IconButton (the
-            # one sanctioned class) carrying the Lucide x
-            # (Admin::Icon), its aria-label the name the glyph cannot
-            # show; the card UI's removal register — low-contrast at
-            # rest, danger on hover — is admin.css's.
-            if birthday
-              clear = "$el.closest('.date-row').querySelectorAll('select, input').forEach(el => el.value = '')"
-              button(type: "button", class: "icon-button", data_size: "sm",
-                     aria_label: "Remove birthday", "@click": clear) do
-                render Icon.new(:x)
-              end
+            birthday_controls(birthday)
+            # The remove control: three blanks is the rule that
+            # removes and this is its one-click spelling — @click
+            # empties the row's controls, the blank rule and the
+            # struck caption do the rest, and retyping the date is
+            # the undo. Gloss's IconButton (the one sanctioned
+            # class) carrying the Lucide x (Admin::Icon), its
+            # aria-label the name the glyph cannot show; the card
+            # UI's removal register — low-contrast at rest, danger
+            # on hover — is admin.css's.
+            clear = "$el.closest('.date-row').querySelectorAll('select, input').forEach(el => el.value = '')"
+            button(type: "button", class: "icon-button", data_size: "sm",
+                   aria_label: "Remove birthday", "@click": clear) do
+              render Icon.new(:x)
             end
           end
         end
       end
 
+      # The birthday row's three controls, shared by the standing
+      # row and the add dialog's — month-day-year to match the prose
+      # Birthday#to_s renders; a select for the month because a name
+      # is the friendlier read and constrains the value by
+      # construction, number inputs with native ranges for the day
+      # and year. The same names for both callers is the point: a
+      # birthday needs no digest, there being at most one, and the
+      # save reads either caller's row identically. A nil birthday
+      # is the added row — nothing selected, nothing prefilled.
+      #: (Birthday? birthday, ?focus: bool) -> void
+      def birthday_controls(birthday, focus: false)
+        select(name: "birthday[month]", aria_label: "month",
+               "x-init": focus ? "$el.focus()" : nil) do
+          option(value: "", selected: birthday&.month.nil?) { "month" }
+          Date::MONTHNAMES.compact.each_with_index do |name, index|
+            option(value: index + 1, selected: birthday&.month == index + 1) { name }
+          end
+        end
+        input(type: "number", name: "birthday[day]", value: birthday&.day,
+              min: 1, max: 31, placeholder: "day", aria_label: "day")
+        input(type: "number", name: "birthday[year]", value: birthday&.year,
+              min: 1, max: 9999, placeholder: "year", aria_label: "year")
+      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
@@ -341,8 +374,10 @@ module ProTacts
       # the form, and what it changes is visible in the card the
       # moment the popover closes.
       #
-      # The three options are the three kinds of row the save can
-      # insert; a fourth, birthday, arrives with its own doc.
+      # The options are the kinds of row the save can insert, the
+      # birthday only over a contact that holds none (#addable_types)
+      # — and its radio leaves the list the moment its row stands,
+      # so the dialog never names what cannot be added again.
       #
       # The radios share a `name` so they are one native group —
       # arrow-key navigation and "1 of n" come from that, not from
@@ -360,10 +395,14 @@ module ProTacts
         dialog(id: "add-property", popover: "auto") do
           header { "Add a property" }
           div(class: "field-stack", role: "radiogroup", aria_label: "Type") do
-            ADDABLE_TYPES.each do |kind|
+            addable_types.each do |kind|
               # A label wrapping its own radio is Gloss's Radio, told
               # apart from Field structurally rather than by class.
-              label do
+              # The birthday's radio leaves the list once its row
+              # stands — x-show rather than a template x-if, so the
+              # native group keeps its shape and the option only
+              # disappears.
+              label("x-show": kind == "birthday" ? "!added.includes('birthday')" : nil) do
                 input(type: "radio", name: "add-type", value: kind, "x-model": "type")
                 plain kind
               end
@@ -376,12 +415,28 @@ module ProTacts
             # `popovertargetaction`: a button cannot both run this
             # handler and carry the declarative hide, and the row has
             # to exist before the dialog goes away or `x-init`'s focus
-            # lands on an element nobody can see.
+            # lands on an element nobody can see. The guard on the
+            # push is the birthday's: a hidden radio can stay the
+            # model's pick (x-model holds its last value), so Add
+            # itself refuses a second one — the row that cannot usefully
+            # repeat, its fields a group Rack would collapse to the
+            # last copy.
             button(type: "button", data: {variant: "primary"},
-                   "@click": "added.push(type); $el.closest('dialog').hidePopover()") { "Add" }
+                   "@click": "(type !== 'birthday' || !added.includes('birthday')) && added.push(type); " \
+                            "$el.closest('dialog').hidePopover()") { "Add" }
           end
         end
       end
+
+      # The dialog's list: everything the save can insert, minus a
+      # birthday the contact already holds — one being the most a
+      # card carries, and the dialog naming only what can still be
+      # added. The row is the same guard from the other side,
+      # rendering only over a held birthday.
+      #: () -> Array[String]
+      def addable_types
+        @contact.birthday ? ADDABLE_TYPES - ["birthday"] : ADDABLE_TYPES
+      end
     end
   end
 end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index c1634a5..6c57447 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -665,10 +665,13 @@ module ProTacts
 
       # The birthday row's parse — request validity, standing with the
       # name check rather than the stored-state conditionals below
-      # (write_card's ordering rule). A POST from anything but this
-      # form carries no birthday group and keeps the model, the
-      # phones' is-a-Hash posture; a group of three blanks is the
-      # row's blank-equals-absent, a removal. The browser's number
+      # (write_card's ordering rule). A POST that carries no birthday
+      # group keeps the model, the phones' is-a-Hash posture — this
+      # form's own save does when it rendered no birthday row, no
+      # birthday held and none added from the dialog
+      # (Admin::ContactsEdit), and anything else never carried one; a
+      # group of three blanks is the row's blank-equals-absent, a
+      # removal. The browser's number
       # inputs make garbage a hand-crafted POST's own, so the refusal
       # is the name toast's backstop sibling: the constructor's
       # ArgumentError is the grammar's own refusal, caught at the one
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7ea385a..a1a8fe3 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -649,10 +649,13 @@ class AdminContactsPagesTest < Minitest::Test
     assert_equal 404, last_response.status
   end
 
-  # The removal state rides only where blanking deletes: nickname,
-  # note, and birthday render whether or not the contact carries
-  # one, and a box that rendered empty cannot lose anything — no
-  # attribute, no "removed on save" in it, no remove button.
+  # The removal state rides only where blanking deletes: nickname
+  # and note render whether or not the contact carries one, and a
+  # box that rendered empty cannot lose anything — no attribute, no
+  # "removed on save" in it, no remove button. The birthday row
+  # does not render at all over a birthday unheld: absence is added
+  # by the dialog like any other property, not scaffolded as an
+  # empty row — the radio leaves the list the moment its row stands.
   def test_rows_without_a_property_to_lose_state_no_removal
     bare = ADA.sub("NOTE:Countess of Lovelace.\r\n", "").sub("BDAY:1985-12-10\r\n", "")
 
@@ -662,7 +665,9 @@ class AdminContactsPagesTest < Minitest::Test
       body = last_response.body
       assert_includes body, '<label class="field"><span>Nickname</span><input type="text" name="nickname"></label>'
       assert_includes body, '<textarea name="note" rows="4"></textarea>'
-      assert_includes body, '<div class="field"><span>birthday</span><div class="date-row">'
+      refute_includes body, '<span>birthday</span>'
+      assert_includes body,
+        '<label x-show="!added.includes(\'birthday\')"><input type="radio" name="add-type" value="birthday" x-model="type">birthday</label>'
       refute_includes body, "icon-button"
     end
   end
@@ -1126,6 +1131,9 @@ class AdminContactsPagesTest < Minitest::Test
       # glyph carries the label.
       assert_includes body,
         %(<button type="button" class="icon-button" data-size="sm" aria-label="Remove birthday" @click="$el.closest('.date-row').querySelectorAll('select, input').forEach(el => el.value = '')"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></button>)
+      # A held birthday is not addable again: the dialog's list drops
+      # the type.
+      refute_includes body, 'value="birthday"'
     end
   end
 
@@ -1197,9 +1205,32 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # A POST from anything but this form carries no birthday group and
-  # keeps the model, the phones' is-a-Hash posture — a group the
-  # request never carried touches nothing.
+  # A birthday added from the dialog saves like one edited: the
+  # added row names the same birthday[] group the standing row
+  # would, the group parses into the model, and the served card
+  # composes the line over the card's untouched bytes.
+  def test_a_birthday_added_from_the_dialog_saves
+    bare = ADA.sub("BDAY:1985-12-10\r\n", "")
+
+    with_contacts({"ada" => bare}) do |store|
+      assert_nil store.contact("ada").birthday
+
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               birthday: {"month" => "3", "day" => "14", "year" => "1879"}
+
+      assert_equal 303, last_response.status
+      contact = store.contact("ada")
+      assert_equal ProTacts::Birthday.new(year: 1879, month: 3, day: 14), contact.birthday
+      assert_includes contact.vcard.to_s, "BDAY:1879-03-14\r\n"
+
+      follow_redirect!
+      assert_includes last_response.body, "March 14, 1879"
+    end
+  end
+
+  # A POST that carries no birthday group keeps the model, the
+  # phones' is-a-Hash posture — a group the request never carried
+  # touches nothing, this form's own no-row save included.
   def test_a_save_without_birthday_fields_leaves_the_model_alone
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag
@@ -1278,9 +1309,10 @@ 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 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.
+      # A radio group of the kinds the save can insert, minus the
+      # birthday this contact holds (#addable_types). 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>' \
@@ -1290,7 +1322,11 @@ class AdminContactsPagesTest < Minitest::Test
         '<button type="button" popovertarget="add-property" popovertargetaction="hide">Cancel</button>'
       # Add makes the row and then hides the popover, in that order —
       # the row has to exist before x-init's focus can land on it.
-      assert_includes body, %(@click="added.push(type); $el.closest('dialog').hidePopover()")
+      # The guard refuses a second birthday: a hidden radio can stay
+      # x-model's pick, and the row's fields are one group Rack
+      # would collapse to the last copy.
+      assert_includes body,
+        %(@click="(type !== 'birthday' || !added.includes('birthday')) && added.push(type); $el.closest('dialog').hidePopover()")
     end
   end
 
@@ -1310,7 +1346,7 @@ class AdminContactsPagesTest < Minitest::Test
       assert_includes body,
         '<template x-for="(kind, i) in added" :key="i"><div class="field">' \
         '<span x-text="kind"></span>' \
-        '<template x-if="kind !== \'address\'">' \
+        '<template x-if="kind !== \'address\' && kind !== \'birthday\'">' \
         '<input :type="kind === \'phone\' ? \'tel\' : kind" :name="`new_${kind}[]`" :aria-label="kind" x-init="$el.focus()">' \
         '</template>' \
         '<template x-if="kind === \'address\'">' \
@@ -1321,6 +1357,19 @@ class AdminContactsPagesTest < Minitest::Test
         '<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>'
+      # The birthday kind: the standing row's own three controls with
+      # nothing prefilled, naming the model's birthday[] group — no
+      # digest, a contact holding at most one, and no removal state,
+      # the added rows' exemption. Asserted in two contiguous pieces:
+      # the month names sit between the empty option and December.
+      assert_includes body,
+        '<template x-if="kind === \'birthday\'"><div class="date-row">' \
+        '<select name="birthday[month]" aria-label="month" x-init="$el.focus()"><option value="" selected>month</option>'
+      assert_includes body,
+        '<option value="12">December</option></select>' \
+        '<input type="number" name="birthday[day]" min="1" max="31" placeholder="day" aria-label="day">' \
+        '<input type="number" name="birthday[year]" min="1" max="9999" placeholder="year" aria-label="year">' \
         '</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