admin: one RecordCard for the form cards
The upload form wore the record editor's field-stack rhythm with no
grid to align to, which was the tightness the /import cleanup set out
to fix.

Assisted-by: GLM-5.3 via pi
change vyspwzmnxswxvznynsmvssqlntttpwmx
commit 9b783d063f5f54de804a0762e1cc9bbef796db5f
author Alpha Chen <alpha@kejadlen.dev>
date
parent f1ed73d3
diff --git a/Steepfile b/Steepfile
index ae836d7..81e88a3 100644
--- a/Steepfile
+++ b/Steepfile
@@ -23,7 +23,7 @@ target :lib do
   ignore(*%w[
     avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
     group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_review import_sidebar
-    import_upload layout list_item phlex upcoming_birthdays
+    import_upload layout list_item phlex record_card upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 6628f9e..943fb29 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -6,6 +6,7 @@ require "pro_tacts/admin/format"
 require "pro_tacts/admin/icons"
 require "pro_tacts/admin/layout"
 require "pro_tacts/admin/name_pair"
+require "pro_tacts/admin/record_card"
 
 module ProTacts
   module Admin
@@ -137,23 +138,17 @@ module ProTacts
           # this pass, `type` is the radio's binding, and the rest is
           # the name boxes' (NamePair).
           div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}', #{NamePair.state(@first, @last)} }") do
-            # The caption-to-card block the details page uses (.record
-            # in admin.css): the back link is this card's caption row,
-            # and the row's one action sits at its right edge — the
-            # same place, and the same shape, as the details page's
-            # edit link. Each mode's row names the other thing you can
-            # do to the record from it.
-            walked do
-            div(class: "record") do
-            div(class: "record-nav") do
-              href, label = @back
-              a(href:, class: "type-label") { "‹ #{label}" }
-              button(type: "button", data_size: "sm",
-                     popovertarget: "add-property") { "add property" }
-            end
-            paired do
-            div(class: "card") do
-              div(class: "card-body") do
+            render RecordCard.new(
+              back: @back,
+              nav: -> {
+                button(type: "button", data_size: "sm",
+                       popovertarget: "add-property") { "add property" }
+              },
+              aside: @aside,
+              sidebar: @sidebar,
+              submit: @save,
+              form: FORM,
+            ) do
                 form(action: @action, method: "post", class: "field-stack", id: FORM) do
                   input(type: "hidden", name: "etag", value: @contact.etag)
                   # The caption is an element rather than bare text
@@ -220,19 +215,6 @@ module ProTacts
                   render @fields if @fields
                 end
               end
-              # The card's action bar, a dialog footer's shape (admin.css),
-              # outside the form and reaching it by id. 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.
-              footer do
-                a(href: @back.fetch(0), class: "btn") { "Cancel" }
-                button(type: "submit", form: FORM, data: {variant: "primary"}) { @save }
-              end
-            end
-            end
-            end
-            end
             add_property_dialog
           end
         end
@@ -240,37 +222,6 @@ module ProTacts
 
       private
 
-      # The walk's list of contacts, and the record beside it when
-      # there is one — the sidebar grid (admin.css). Skipped rather
-      # than rendered around one column for the reason #paired is:
-      # an ordinary edit has no walk to stand in.
-      #: () { () -> void } -> void
-      def walked
-        sidebar = @sidebar
-        return yield if sidebar.nil?
-
-        div(class: "walk") do
-          render sidebar
-          yield
-        end
-      end
-
-      # The editor's card, and what stands beside it when something
-      # does — the two-column grid the dashboard uses, for its reason
-      # (admin.css). With nothing beside it the wrapper is skipped
-      # rather than rendered around one card: a grid of one is a
-      # different element for no difference on screen.
-      #: () { () -> void } -> void
-      def paired
-        aside = @aside
-        return yield if aside.nil?
-
-        div(class: "paired") do
-          render aside
-          yield
-        end
-      end
-
       # The rows added this pass, rendered by Alpine from `added` —
       # one per type named in the dialog, in the order they were
       # asked for. Nothing stands here until something is added and
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index cac5949..157dbaf 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -61,7 +61,7 @@ module ProTacts
               div(class: "card-body") do
                 div(class: "detail-header") do
                   div do
-                    h1(class: "type-h2", style: "margin: 0;") { @contact.name || @contact.id }
+                    h1(class: "type-h2") { @contact.name || @contact.id }
                     # The nickname rides under the name in the heading
                     # family — type-h3 to the name's h2 — so it reads as
                     # a name rather than a property value; muted, so the
diff --git a/lib/pro_tacts/admin/device_setup.rb b/lib/pro_tacts/admin/device_setup.rb
index 02f1385..28c607a 100644
--- a/lib/pro_tacts/admin/device_setup.rb
+++ b/lib/pro_tacts/admin/device_setup.rb
@@ -36,7 +36,7 @@ module ProTacts
             end
             div(class: "card") do
               div(class: "card-body") do
-                h1(class: "type-h2", style: "margin: 0;") { "CardDAV profile" }
+                h1(class: "type-h2") { "CardDAV profile" }
                 dl(class: "detail-grid") do
                   dt(class: "type-label") { "server" }
                   dd(class: "type-body-sm") { @hostname }
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 7bdc52b..9b2e7ad 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -4,6 +4,7 @@ require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
+require "pro_tacts/admin/record_card"
 
 module ProTacts
   module Admin
@@ -42,42 +43,34 @@ module ProTacts
           # The scope wraps the whole record because the add button sits
           # in the caption row above the card, where the contact editor's
           # add button sits, and the row it reveals is inside the form.
-          div(class: "record", x_data: "{ adding: false }") do
-            div(class: "record-nav") do
-              a(href: "/groups/#{@group.id}", class: "type-label") do
-                plain "‹ "
-                render GroupLabel.new(group: @group)
-              end
-              button(type: "button", data_size: "sm", "x-show": "!adding",
-                     "@click": "adding = true") { "add address" }
-            end
-            div(class: "card") do
-              div(class: "card-body") do
-                form(action: "/groups/#{@group.id}", method: "post", class: "field-stack", id: FORM) do
-                  input(type: "hidden", name: "version", value: @group.version)
-                  # The id as the blank's placeholder, because a group
-                  # with no name is shown as its id everywhere else.
-                  label(class: "field") do
-                    span { "Name" }
-                    input(type: "text", name: "name", value: @group.name,
-                          placeholder: @group.id, autofocus: true)
-                  end
-                  @reading.addresses.each { |address| address_row(address) }
-                  added_address_row
-                  # The first NOTE, the contact editor's own rule: this
-                  # row edits one, and a save writes one.
-                  note = @reading.notes.first&.value
-                  label(class: "field", data: {blank_removes: !!note}) do
-                    span { "Note" }
-                    textarea(name: "note", rows: 4,
-                             placeholder: note ? "removed on save" : nil) { note.to_s }
-                  end
+          div(x_data: "{ adding: false }") do
+            render RecordCard.new(
+              back: ["/groups/#{@group.id}", GroupLabel.new(group: @group)],
+              nav: -> {
+                button(type: "button", data_size: "sm", "x-show": "!adding",
+                       "@click": "adding = true") { "add address" }
+              },
+              submit: "Save", form: FORM,
+            ) do
+              form(action: "/groups/#{@group.id}", method: "post", class: "field-stack", id: FORM) do
+                input(type: "hidden", name: "version", value: @group.version)
+                # The id as the blank's placeholder, because a group
+                # with no name is shown as its id everywhere else.
+                label(class: "field") do
+                  span { "Name" }
+                  input(type: "text", name: "name", value: @group.name,
+                        placeholder: @group.id, autofocus: true)
+                end
+                @reading.addresses.each { |address| address_row(address) }
+                added_address_row
+                # The first NOTE, the contact editor's own rule: this
+                # row edits one, and a save writes one.
+                note = @reading.notes.first&.value
+                label(class: "field", data: {blank_removes: !!note}) do
+                  span { "Note" }
+                  textarea(name: "note", rows: 4,
+                           placeholder: note ? "removed on save" : nil) { note.to_s }
                 end
-              end
-              # ContactsEdit's action bar.
-              footer do
-                a(href: "/groups/#{@group.id}", class: "btn") { "Cancel" }
-                button(type: "submit", form: FORM, data: {variant: "primary"}) { "Save" }
               end
             end
           end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index fcc250d..dd4504d 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -34,7 +34,7 @@ module ProTacts
             div(class: "card") do
               div(class: "card-body") do
                 div(class: "detail-header") do
-                  h1(class: "type-h2", style: "margin: 0;") { render GroupLabel.new(group: @group) }
+                  h1(class: "type-h2") { render GroupLabel.new(group: @group) }
                 end
                 # Never empty, unlike ContactsShow's: the members row
                 # always renders (#members_row).
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
index fa3b362..1172b3e 100644
--- a/lib/pro_tacts/admin/import_review.rb
+++ b/lib/pro_tacts/admin/import_review.rb
@@ -69,7 +69,7 @@ module ProTacts
       def summary_card
         div(class: "card") do
           div(class: "card-body") do
-            h1(class: "type-h2", style: "margin: 0;") { count(@contacts, "contact") }
+            h1(class: "type-h2") { count(@contacts, "contact") }
             losses
             group_line
             progress
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index 1d2c7f3..af0f00d 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -1,6 +1,7 @@
 require "pro_tacts/admin/phlex"
 
 require "pro_tacts/admin/layout"
+require "pro_tacts/admin/record_card"
 
 module ProTacts
   module Admin
@@ -22,6 +23,9 @@ module ProTacts
     class ImportUpload < Phlex::HTML
       # @rbs @notice: String?
 
+      FORM = "import-form" #: String
+      private_constant :FORM
+
       #: (?notice: String?) -> void
       def initialize(notice: nil)
         @notice = notice
@@ -29,33 +33,13 @@ module ProTacts
 
       def view_template
         render Layout.new(title: "Import", notice: @notice) do
-          div(class: "record") do
-            div(class: "record-nav") do
-              a(href: "/", class: "type-label") { "‹ contacts" }
-            end
-            upload_card
-          end
-        end
-      end
-
-      private
-
-      #: () -> void
-      def upload_card
-        div(class: "card") do
-          div(class: "card-body") do
-            h1(class: "type-h2", style: "margin: 0;") { "Import contacts" }
-            p(class: "type-body-sm") do
-              "Choose a vCard file — Contacts exports one with File, Export, Export vCard. " \
-                "The next screen says what is in it before anything is added, including what " \
-                "pro-tacts cannot show and will leave behind."
-            end
-            form(action: "/import", method: "post", enctype: "multipart/form-data", class: "field-stack") do
+          render RecordCard.new(back: ["/", "contacts"], heading: "Import contacts",
+                                submit: "Import", form: FORM) do
+            form(action: "/import", method: "post", enctype: "multipart/form-data", id: FORM) do
               label(class: "field") do
-                span(class: "type-label") { "vcard file" }
+                plain "vcard file"
                 input(type: "file", name: "vcf", accept: ".vcf,text/vcard")
               end
-              button(type: "submit", data: {variant: "primary"}) { "read the file" }
             end
           end
         end
diff --git a/lib/pro_tacts/admin/record_card.rb b/lib/pro_tacts/admin/record_card.rb
new file mode 100644
index 0000000..d50f983
--- /dev/null
+++ b/lib/pro_tacts/admin/record_card.rb
@@ -0,0 +1,118 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+  module Admin
+    # The trunk every form card shares: the record's caption row and
+    # card (admin.css), the form as the card's body, and the action
+    # bar under both — a shape ContactsEdit, GroupsEdit, and
+    # ImportUpload each carried by hand before this.
+    #
+    # The form is the caller's block rather than the component's,
+    # because its rhythm is the caller's question: an editor's rows
+    # are a field-stack on the record's grid, and an upload's lone
+    # field is a Gloss settings field with no grid to align to.
+    # Everything around the form is the same for all three, which is
+    # why it lives here.
+    #
+    # `back` names the caption row's link, `[href, label]`, the label
+    # a string or whatever `render` takes (GroupsEdit's is a
+    # GroupLabel). `nav`, when something is passed, is the row's one
+    # action at its right edge. The footer's Cancel goes where the
+    # back link goes — the editors' own rule — and the submit reaches
+    # the form by id rather than sitting in it, so the action bar is
+    # the card's and not the form's.
+    class RecordCard < Phlex::HTML
+      # @rbs @back: [String, (String | Phlex::HTML | Proc)]
+      # @rbs @nav: (Proc | Phlex::HTML)?
+      # @rbs @heading: String?
+      # @rbs @aside: Phlex::HTML?
+      # @rbs @sidebar: Phlex::HTML?
+      # @rbs @submit: String
+      # @rbs @form: String
+
+      #: (back: [String, (String | Phlex::HTML | Proc)], submit: String, form: String, ?nav: (Proc | Phlex::HTML)?, ?heading: String?, ?aside: Phlex::HTML?, ?sidebar: Phlex::HTML?) -> void
+      def initialize(back:, submit:, form:, nav: nil, heading: nil, aside: nil, sidebar: nil)
+        @back = back
+        @nav = nav
+        @heading = heading
+        @aside = aside
+        @sidebar = sidebar
+        @submit = submit
+        @form = form
+      end
+
+      def view_template(&)
+        sidebar = @sidebar
+        return record(&) if sidebar.nil?
+
+        walk(sidebar, &)
+      end
+
+      private
+
+      # The walk's list of contacts, and the record beside it when
+      # there is one — the sidebar grid (admin.css). Skipped rather
+      # than rendered around one column for the reason #paired is:
+      # an ordinary edit has no walk to stand in.
+      #: (Phlex::HTML sidebar) { () -> void } -> void
+      def walk(sidebar, &)
+        div(class: "walk") do
+          render sidebar
+          record(&)
+        end
+      end
+
+      # The caption-to-card block the details page uses (.record in
+      # admin.css): the back link is this card's caption row, and the
+      # row's one action sits at its right edge — the same place, and
+      # the same shape, as the details page's edit link. Each mode's
+      # row names the other thing you can do to the record from it.
+      def record(&)
+        div(class: "record") do
+          div(class: "record-nav") do
+            href, label = @back
+            a(href:, class: "type-label") do
+              plain "‹ "
+              render label
+            end
+            render @nav if @nav
+          end
+          paired(&)
+        end
+      end
+
+      # The editor's card, and what stands beside it when something
+      # does — the two-column grid the dashboard uses, for its reason
+      # (admin.css). With nothing beside it the wrapper is skipped
+      # rather than rendered around one card: a grid of one is a
+      # different element for no difference on screen.
+      #: () { () -> void } -> void
+      def paired(&)
+        aside = @aside
+        return card(&) if aside.nil?
+
+        div(class: "paired") do
+          render aside
+          card(&)
+        end
+      end
+
+      def card(&)
+        div(class: "card") do
+          div(class: "card-body") do
+            h1(class: "type-h2") { @heading } if @heading
+            yield
+          end
+          # A dialog footer's shape (admin.css), outside the form and
+          # reaching it by id. The submit is the form's; Cancel is
+          # navigation — a link in Gloss's `.btn` contract, which is
+          # what an anchor that acts like a button opts into.
+          footer do
+            a(href: @back.fetch(0), class: "btn") { "Cancel" }
+            button(type: "submit", form: @form, data: {variant: "primary"}) { @submit }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/public/admin.css b/public/admin.css
index 26694c4..26c8487 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -56,6 +56,17 @@
   translate: 0 -50%;
 }
 
+/* A card's h1 carries no margin of its own — Gloss zeroes h3 in a
+   card-body the same way — because every h1 in one is a page's
+   heading and its separation from what follows is the page's rhythm,
+   not the heading element's default. The descendant rule reaches the
+   name inside .detail-header too; the direct-child one adds the one
+   step of air under a card's own heading, which the card-body's gap
+   does not give it — that gap is the prose-block rhythm, and loosening
+   it to air a heading would loosen every paragraph after it. */
+.card-body h1 { margin: 0; }
+.card-body > h1 { margin-block-end: var(--gl-space-xs); }
+
 /* The type column both record modes share: the details grid's label
    column and the editor's caption column are the same column, read
    from one place so they cannot drift apart. The one structural rem