admin: end an editor's card in a dialog's action bar
Save and Cancel leave the form for a footer with a hairline on top;
Save reaches back by the form's id, and .form-actions is dropped.

Assisted-by: Claude Opus 5 via Claude Code
change qosnoursruyvlqwpyxvpsxpzmpkqopwq
commit 936dbea7fe730fd6600719b86a3ae69ac4e2b58a
author Alpha Chen <alpha@kejadlen.dev>
date
parent vnmnprxx
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index e68a2a3..3a6caa6 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -62,6 +62,10 @@ module ProTacts
       ADDABLE_TYPES = %w[phone email address birthday].freeze #: Array[String]
       private_constant :ADDABLE_TYPES
 
+      # The form's id, for the footer's Save outside it.
+      FORM = "contact-form" #: String
+      private_constant :FORM
+
       # The address row's component fields, stacked in the value
       # column in the order an address form reads — RFC 2426 section
       # 3.2.1's own order minus the po box, which no screen shows and
@@ -128,7 +132,7 @@ module ProTacts
             end
             div(class: "card") do
               div(class: "card-body") do
-                form(action: "/contacts/#{@contact.id}", method: "post", class: "field-stack") do
+                form(action: "/contacts/#{@contact.id}", 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
                   # because the row is a grid (admin.css): a text node
@@ -198,15 +202,17 @@ module ProTacts
                     textarea(name: "note", rows: 4,
                              placeholder: note ? "removed on save" : nil) { note.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
-                    a(href: "/contacts/#{@contact.id}", class: "btn") { "Cancel" }
-                    button(type: "submit", data: {variant: "primary"}) { "Save" }
-                  end
                 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: "/contacts/#{@contact.id}", class: "btn") { "Cancel" }
+                button(type: "submit", form: FORM, data: {variant: "primary"}) { "Save" }
+              end
             end
             end
             add_property_dialog
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 2840679..95e12cc 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -26,6 +26,10 @@ module ProTacts
       # @rbs @reading: Contact
       # @rbs @notice: String?
 
+      # The form's id, for the footer's Save outside it.
+      FORM = "group-form" #: String
+      private_constant :FORM
+
       #: (group: Store::Group, ?notice: String?) -> void
       def initialize(group:, notice: nil)
         @group = group
@@ -49,7 +53,7 @@ module ProTacts
             end
             div(class: "card") do
               div(class: "card-body") do
-                form(action: "/groups/#{@group.id}", method: "post", class: "field-stack") 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.
@@ -68,12 +72,13 @@ module ProTacts
                     textarea(name: "note", rows: 4,
                              placeholder: note ? "removed on save" : nil) { note.to_s }
                   end
-                  div(class: "form-actions") do
-                    a(href: "/groups/#{@group.id}", class: "btn") { "Cancel" }
-                    button(type: "submit", data: {variant: "primary"}) { "Save" }
-                  end
                 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
         end
diff --git a/public/admin.css b/public/admin.css
index 59653c5..f00c959 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -280,17 +280,6 @@
   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.
-   Cancel comes first and the primary action last, the order every
-   dialog's footer uses. */
-.form-actions {
-  display: flex;
-  gap: var(--gl-space-2xs);
-  margin-inline-start: calc(var(--type-column) + var(--gl-space-s));
-}
-
 /* 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 beside a .field caption. The box mirrors field.css's
@@ -636,6 +625,17 @@ dialog > :not(header):not(footer) {
   padding: var(--gl-space-s);
 }
 
+/* An editor's card ends in a dialog's action bar (ContactsEdit,
+   GroupsEdit), restated for a card, which has no footer of its own.
+   Its sides are the card body's, so Save lines up with the fields. */
+.card > footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: var(--gl-space-2xs);
+  padding: var(--gl-space-2xs) var(--gl-space-m);
+  border-top: 1px solid var(--gl-color-border-hairline);
+}
+
 /* A dialog's lists of one-line rows — the groups dialog's checkboxes
    (Admin::GroupDialog), and the add dialog's radios (ContactsEdit)
    through the first rule — are not the settings form Gloss spaces its
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 233acae..c56eaa7 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -872,7 +872,8 @@ class AdminContactsPagesTest < Minitest::Test
 
       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, '<form action="/contacts/red" method="post" class="field-stack" id="contact-form">'
+      assert_includes body, '<button type="submit" form="contact-form" data-variant="primary">Save</button>'
       assert_includes body, '<input type="text" name="first" value="Ada" ' \
                             ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
       assert_includes body, '<input type="text" name="last" value="Lovelace" ' \
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index b112edd..d8ed38b 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -146,6 +146,17 @@ class AdminGroupsPagesTest < Minitest::Test
 
   ## The editor
 
+  def test_the_editors_save_submits_its_form
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      get "/groups/#{id}/edit"
+
+      assert_includes last_response.body, %(id="group-form")
+      assert_includes last_response.body, '<button type="submit" form="group-form" data-variant="primary">Save</button>'
+    end
+  end
+
   def test_the_editor_leaves_membership_to_the_card
     with_contacts(BOOLES) do |store|
       id = household(store)