admin: show a blanked row's removal before the save
Blank-equals-absent was enforced without being shown: a cleared
phone, nickname, or birthday deleted at save with nothing on screen
saying so. The birthday earns a remove button of its own — three
separate blanks being a rule nobody can guess — carrying the first
Lucide glyph, vendored in admin/icons.rb.

Assisted-by: GLM-5.3 via pi
change snooqvnpormzoyttwsxslvnozutuqrlm
commit bc6f16014e9321876299031af38b25abdab88d34
author Alpha Chen <alpha@kejadlen.dev>
date
parent xlnvposy
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 06e19f3..249aaf5 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -50,9 +50,10 @@ What that inheritance actually means:
   this app that's the dialog or a toast.
 - Motion is `--gl-dur-fast` on hover and focus, named properties only, never
   `all`. Press is not animated. Nothing enters, nothing bounces.
-- Icons are Lucide at 16–20px, stroke in `currentColor`, never filled. Nothing
-  under `lib/pro_tacts/admin` vendors an icon set yet — until one lands, a
-  screen leads a row with its mono type label instead of an icon rather than
+- Icons are Lucide at 16–20px, stroke in `currentColor`, never filled. The
+  set is vendored glyph by glyph in `lib/pro_tacts/admin/icons.rb`, inlined
+  because `currentColor` cannot cross an `<img>` — until the glyph a screen
+  wants lands there, it leads the row with its mono type label rather than
   inventing a substitute.
 - No gradients, no blur, no translucency, no photography, no brand mark —
   "Pro-tacts" is rendered in type wherever a mark would go.
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 83d710f..3b98550 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -2,6 +2,7 @@ require "date"
 
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/icons"
 require "pro_tacts/admin/layout"
 
 module ProTacts
@@ -18,7 +19,27 @@ module ProTacts
     #
     # 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
+    # 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
+    # `data-blank-removes`, its single-value box says "removed on
+    # save" in its blank, and admin.css strikes the caption in
+    # Gloss's danger color once every value in the row reads blank —
+    # 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
+    # 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
     # 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.
@@ -110,9 +131,15 @@ module ProTacts
                     span { "Last" }
                     input(type: "text", name: "last", value: @last)
                   end
-                  label(class: "field") do
+                  # The whole-property rows wear the removal state only
+                  # over a property the card carries: both render empty
+                  # when it does not, and a box that started blank
+                  # cannot lose anything — "removed on save" in it
+                  # would be a false alarm.
+                  label(class: "field", data: {blank_removes: !!@contact.nickname}) do
                     span { "Nickname" }
-                    input(type: "text", name: "nickname", value: @contact.nickname)
+                    input(type: "text", name: "nickname", value: @contact.nickname,
+                          placeholder: @contact.nickname ? "removed on save" : nil)
                   end
                   # A phone row edits its value and nothing else: the
                   # TYPE parameters ride in the line's own header,
@@ -127,24 +154,27 @@ module ProTacts
                   # of a pair of byte-identical rows means blanking
                   # one, saving, then editing the other.
                   @contact.phones.each do |phone|
-                    label(class: "field") do
+                    label(class: "field", data: {blank_removes: true}) do
                       span { phone.type || "phone" }
-                      input(type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
+                      input(type: "tel", name: "phone[#{phone.line.digest}]",
+                            value: phone.value, placeholder: "removed on save")
                     end
                   end
                   # An email row, the phone row's own shape over EMAIL.
                   @contact.emails.each do |email|
-                    label(class: "field") do
+                    label(class: "field", data: {blank_removes: true}) do
                       span { email.type || "email" }
-                      input(type: "email", name: "email[#{email.line.digest}]", value: email.value)
+                      input(type: "email", name: "email[#{email.line.digest}]",
+                            value: email.value, placeholder: "removed on save")
                     end
                   end
                   @contact.addresses.each { address_row(it) }
                   birthday_row
                   added_rows
-                  label(class: "field") do
+                  label(class: "field", data: {blank_removes: !!@contact.notes}) do
                     span { "Note" }
-                    textarea(name: "note", rows: 4) { @contact.notes.to_s }
+                    textarea(name: "note", rows: 4,
+                             placeholder: @contact.notes ? "removed on save" : nil) { @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
@@ -170,6 +200,9 @@ module ProTacts
       # nothing lingers after: an empty row waiting to be used is the
       # 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 —
+      # 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).
       #
@@ -226,11 +259,13 @@ module ProTacts
       # 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.
+      # 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.
       #: () -> void
       def birthday_row
         birthday = @contact.birthday
-        div(class: "field") do
+        div(class: "field", data: {blank_removes: !!birthday}) do
           span { "birthday" }
           div(class: "date-row") do
             select(name: "birthday[month]", aria_label: "month") do
@@ -243,6 +278,22 @@ module ProTacts
                   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
+            end
           end
         end
       end
@@ -255,10 +306,15 @@ module ProTacts
       # names one — each input carries its placeholder as its
       # aria-label instead, and the caption is the type's. The digest
       # in each field's name is the row's address; the po box has no
-      # field, and the save preserves its bytes.
+      # 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
+      # this form at all — its row wears no state however blank it
+      # renders.
       #: (Contact::Address address) -> void
       def address_row(address)
-        div(class: "field") do
+        div(class: "field", data: {blank_removes: address.po_box.nil?}) do
           span { address.type || "address" }
           div(class: "field-stack") do
             ADDRESS_FIELDS.each do |component, label|
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
new file mode 100644
index 0000000..e989366
--- /dev/null
+++ b/lib/pro_tacts/admin/icons.rb
@@ -0,0 +1,43 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+  module Admin
+    # Lucide icons, inlined: docs/DESIGN.md's icon register — Lucide
+    # at 16–20px, stroke in currentColor, never filled — and this is
+    # the set landing there. The path data below is lucide-static's
+    # own (v0.548.0, ISC license), copied verbatim rather than redrawn,
+    # and inlined rather than served as a file because currentColor
+    # cannot cross an <img>: the stroke has to inherit from the
+    # element that carries the glyph, Gloss's IconButton included. A
+    # new glyph lands the same way this one did — named in the
+    # registry, its paths verbatim, this comment's version moved with
+    # it.
+    class Icon < Phlex::SVG
+      # @rbs @name: Symbol
+      # @rbs @size: Integer
+
+      # One glyph's path data, verbatim from the lucide-static SVG's
+      # own path elements — x, the remove control's two strokes.
+      PATHS = {
+        x: ["M18 6 6 18", "m6 6 12 12"],
+      } #: Hash[Symbol, Array[String]]
+
+      #: (Symbol name, ?size: Integer) -> void
+      def initialize(name, size: 16)
+        @name = name
+        @size = size
+      end
+
+      def view_template
+        # viewBox is camelCase on purpose: SVG attribute names are
+        # case-sensitive, and Phlex's underscore-to-dash convention
+        # would render view_box as view-box, which no browser reads.
+        svg(viewBox: "0 0 24 24", width: @size, height: @size, fill: :none,
+            stroke: "currentColor", stroke_width: 2, stroke_linecap: "round",
+            stroke_linejoin: "round", aria_hidden: true) do
+          PATHS.fetch(@name).each { path(d: it) }
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/phlex.rb b/lib/pro_tacts/admin/phlex.rb
index aa13b18..4be6959 100644
--- a/lib/pro_tacts/admin/phlex.rb
+++ b/lib/pro_tacts/admin/phlex.rb
@@ -8,7 +8,10 @@ require "pro_tacts/warnings"
 # own files eagerly inside the silence; the views require this file
 # instead of phlex, and every later require or autoload hit is a
 # no-op. sgml/state is in the set because it loads later still, at
-# the first render, and carries its own warning.
+# the first render, and carries its own warning. phlex/svg is in the
+# set because Admin::Icon (icons.rb) builds on it and because its
+# element methods re-register over SGML's, one redefined-method
+# warning per tag.
 #
 # Two warnings survive on purpose: sgml.rb passes &block to
 # before_template and after_template, which ignore it, and Ruby warns
@@ -19,5 +22,6 @@ require "pro_tacts/warnings"
 ProTacts.silence_warnings do
   require "phlex"
   require "phlex/html"
+  require "phlex/svg"
   require "phlex/sgml/state"
 end
diff --git a/public/admin.css b/public/admin.css
index 2e0417e..ac60779 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -147,6 +147,18 @@
   width: calc(7ch + 2 * var(--gl-space-s) + 2px);
   padding-block: var(--gl-space-3xs);
 }
+/* The birthday row's remove control (Admin::ContactsEdit): Gloss's
+   IconButton is already square, flex-none, and quiet — the one thing
+   this layer adds is centering it on the date line, whose children
+   stretch, and the removal register the card UI claims for itself
+   (docs/DESIGN.md): danger only on hover, the icon's stroke
+   inheriting it. */
+.card-body form.field-stack .field > .date-row > .icon-button {
+  align-self: center;
+}
+.card-body form.field-stack .field > .date-row > .icon-button:hover {
+  color: var(--gl-color-danger);
+}
 /* A multiline control has no single line to align a caption to, and
    what a browser calls its baseline is not settled — a scroll
    container's can be synthesized from its border box, which would
@@ -162,6 +174,38 @@
   padding-block-start: calc(var(--gl-space-3xs) + 1px);
 }
 
+/* Blank-equals-absent, stated before the save: a row wearing
+   data-blank-removes is one whose blanking deletes something
+   (Admin::ContactsEdit owns each row's decision to wear it), and
+   once every value in the row reads blank the caption strikes and
+   takes the danger color — the row's own name crossed out in the
+   color Gloss reserves for destructive, which this pending
+   deletion is. CSS over :placeholder-shown rather than Alpine,
+   because revealing a state over standing elements is the job
+   markup keeps here (see Admin::Layout). The single-value rows
+   also say it in the blanked box itself, their placeholder dyed
+   to match; the structured rows carry the struck caption alone,
+   each selector naming its row's own removal condition — an
+   address only blank throughout its components, a partial blank
+   keeping the line, and the birthday only with the month's own
+   empty option picked, both the save's rules. The boxes keep
+   their neutral borders: Gloss's danger border is the
+   aria-invalid contract, and a blank row is a valid save, not a
+   refused one. One gap, noted rather than scripted: a
+   whitespace-only value hides the placeholder and the strike with
+   it, while the save strips before it compares. */
+.field[data-blank-removes]:has(> input:placeholder-shown) > span,
+.field[data-blank-removes]:has(> textarea:placeholder-shown) > span,
+.field[data-blank-removes]:has(> .field-stack):not(:has(input:not(:placeholder-shown))) > span,
+.field[data-blank-removes]:has(> .date-row):has(option[value=""]:checked):not(:has(input:not(:placeholder-shown))) > span {
+  text-decoration: line-through;
+  color: var(--gl-color-danger);
+}
+.field[data-blank-removes]:has(> input:placeholder-shown) > input::placeholder,
+.field[data-blank-removes]:has(> textarea:placeholder-shown) > textarea::placeholder {
+  color: var(--gl-color-danger);
+}
+
 /* The editor's submit row: the buttons sit beside each other under
    the fields, rather than in the stack's own gap, and start at the
    value column so the form keeps one edge down its whole height. */
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index a00925f..7ea385a 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -633,8 +633,11 @@ class AdminContactsPagesTest < Minitest::Test
       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>"
+      assert_includes body,
+        '<label class="field" data-blank-removes><span>Nickname</span>' \
+        '<input type="text" name="nickname" value="Red" placeholder="removed on save"></label>'
+      assert_includes body,
+        "<textarea name=\"note\" rows=\"4\" placeholder=\"removed on save\">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
@@ -646,6 +649,42 @@ 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.
+  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", "")
+
+    with_contacts({"ada" => bare}) do
+      get "/contacts/ada/edit"
+
+      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, "icon-button"
+    end
+  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.
+  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",
+      "ADR;TYPE=home:PO Box 1;;;;;",
+    )
+
+    with_contacts({"ada" => po}) do
+      get "/contacts/ada/edit"
+
+      assert_includes last_response.body,
+        '<div class="field"><span>home</span><div class="field-stack">'
+    end
+  end
+
   # The details page names its other mode: the edit link rides the
   # back-link's line at the right edge, so the card below stays a
   # clean read of the record.
@@ -777,7 +816,9 @@ class AdminContactsPagesTest < Minitest::Test
 
       digest = store.contact("ada").phones.first.line.digest
       body = last_response.body
-      assert_includes body, '<label class="field"><span>mobile</span><input type="tel" name="phone[' + digest + ']" value="+1-555-0100"></label>'
+      assert_includes body,
+        '<label class="field" data-blank-removes><span>mobile</span>' \
+        '<input type="tel" name="phone[' + digest + ']" value="+1-555-0100" placeholder="removed on save"></label>'
     end
   end
 
@@ -792,7 +833,8 @@ class AdminContactsPagesTest < Minitest::Test
 
       digest = store.contact("ada").emails.first.line.digest
       assert_includes last_response.body,
-        '<label class="field"><span>home</span><input type="email" name="email[' + digest + ']" value="ada@example.com"></label>'
+        '<label class="field" data-blank-removes><span>home</span>' \
+        '<input type="email" name="email[' + digest + ']" value="ada@example.com" placeholder="removed on save"></label>'
     end
   end
 
@@ -895,7 +937,7 @@ class AdminContactsPagesTest < Minitest::Test
       digest = store.contact("ada").addresses.first.line.digest
       body = last_response.body
       assert_includes body,
-        '<div class="field"><span>home</span><div class="field-stack">' \
+        '<div class="field" data-blank-removes><span>home</span><div class="field-stack">' \
         '<input type="text" name="address[' + digest + '][street]" value="12 Analytical Way" placeholder="street" aria-label="street">' \
         '<input type="text" name="address[' + digest + '][extended]" placeholder="street 2" aria-label="street 2">' \
         '<input type="text" name="address[' + digest + '][locality]" value="London" placeholder="city" aria-label="city">'
@@ -1072,13 +1114,18 @@ class AdminContactsPagesTest < Minitest::Test
 
       body = last_response.body
       assert_includes body,
-        '<div class="field"><span>birthday</span><div class="date-row">' \
+        '<div class="field" data-blank-removes><span>birthday</span><div class="date-row">' \
         '<select name="birthday[month]" aria-label="month"><option value="">month</option>'
       assert_includes body, '<option value="12" selected>December</option></select>'
       assert_includes body,
         '<input type="number" name="birthday[day]" value="10" min="1" max="31" placeholder="day" aria-label="day">'
       assert_includes body,
         '<input type="number" name="birthday[year]" value="1985" min="1" max="9999" placeholder="year" aria-label="year">'
+      # The remove control: one click that speaks the three-blank
+      # rule, over a held birthday only — Gloss's IconButton, so the
+      # 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>)
     end
   end