admin: edit rows take the details grid's columns
Stacked captions spent a line and a gap on every row, running the
form well past a screen once a contact had a few phones.

Assisted-by: Claude Opus 5 via Claude Code
change yuqwpuovuykokkxxzywpytnnzvurmwqq
commit 9f90c84a65770b17087a3d992f3ce7294cb9c46c
author Alpha Chen <alpha@kejadlen.dev>
date
parent plklqoop
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 038e70a..34857f5 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -56,17 +56,22 @@ module ProTacts
               div(class: "card-body") do
                 form(action: "/contacts/#{@contact.id}", method: "post", class: "field-stack") 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
+                  # would still land in the type column as an
+                  # anonymous grid item, but nothing could then reach
+                  # it, and the note's caption needs reaching.
                   label(class: "field") do
-                    plain "First"
+                    span { "First" }
                     input(type: "text", name: "first", value: @first,
                           required: true, autofocus: true)
                   end
                   label(class: "field") do
-                    plain "Last"
+                    span { "Last" }
                     input(type: "text", name: "last", value: @last)
                   end
                   label(class: "field") do
-                    plain "Nickname"
+                    span { "Nickname" }
                     input(type: "text", name: "nickname", value: @contact.nickname)
                   end
                   # A phone row edits its value and nothing else: the
@@ -83,7 +88,7 @@ module ProTacts
                   # one, saving, then editing the other.
                   @contact.phones.each do |phone|
                     label(class: "field") do
-                      plain phone.type || "phone"
+                      span { phone.type || "phone" }
                       input(type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
                     end
                   end
@@ -91,11 +96,11 @@ module ProTacts
                   # END:VCARD, and blank inserts nothing — inserting
                   # absence is a no-op, the plan's rule for new rows.
                   label(class: "field") do
-                    plain "add phone"
+                    span { "add phone" }
                     input(type: "tel", name: "new_phone")
                   end
                   label(class: "field") do
-                    plain "Note"
+                    span { "Note" }
                     textarea(name: "note", rows: 4) { @contact.notes.to_s }
                   end
                   # Save is the form's submit; Cancel is navigation — a
diff --git a/public/admin.css b/public/admin.css
index 0e4ccf8..356e922 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -109,28 +109,72 @@
   translate: 0 -50%;
 }
 
+/* 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
+   width this layer names — see .detail-grid for what it holds. */
+:root {
+  --type-column: 6rem;
+}
+
 /* A form in a card keeps a tighter rhythm than Gloss's form-body
-   defaults: the stack closes from s to xs, and the boxes shed half
-   their block padding — the same shedding the header's search does —
-   so short boxes sit close under their captions. Scoped to the
-   card-body form rather than overriding field.css globally; the
-   label in the input selector out-specifies field.css's input rule
-   rather than relying on stylesheet order (see the header search's
-   note). */
-.card-body .field-stack { gap: var(--gl-space-xs); }
+   defaults: the boxes shed half their block padding — the same
+   shedding the header's search does — and the stack closes to the
+   detail grid's row gap, because a row is now one line tall there
+   too. Scoped to the card-body form rather than overriding field.css
+   globally; the label in the input selector out-specifies field.css's
+   input rule rather than relying on stylesheet order (see the header
+   search's note). */
+.card-body .field-stack { gap: var(--gl-space-2xs); }
 .card-body label.field > input,
 .card-body label.field > textarea { padding-block: var(--gl-space-2xs); }
 
+/* An editor row is a detail row that happens to be editable: caption
+   in the type column, control in the value column, on .detail-grid's
+   own template (docs/DESIGN.md, "Alignment is the layout" — the same
+   record before and after an edit should read as the same object).
+   Gloss's .field stacks its caption over its control, which is right
+   for the settings-form pattern it was written for and wrong for a
+   record: it spends a line and a gap on every row, and this card is
+   eight rows deep before a contact has an email. Baseline for the
+   reason .detail-grid gives — caption and control carry different
+   type sizes, so lining their boxes up leaves their text on
+   different lines. Scoped to the card body: the create dialog's two
+   fields keep the stacked shape, which is what a narrow popover
+   wants. */
+.card-body label.field {
+  display: grid;
+  grid-template-columns: var(--type-column) 1fr;
+  column-gap: var(--gl-space-s);
+  align-items: baseline;
+}
+/* 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
+   drop the caption to the bottom of the note. So this row aligns at
+   the top instead, and the caption is pushed down by exactly what
+   holds the textarea's first line off its own edge: the block
+   padding above plus the border. Derived, not eyeballed, so the two
+   move together if either changes. */
+.card-body label.field:has(textarea) {
+  align-items: start;
+}
+.card-body label.field:has(textarea) > span {
+  padding-block-start: calc(var(--gl-space-2xs) + 1px);
+}
+
 /* The editor's submit row: the buttons sit beside each other under
-   the stacked fields, rather than in the stack's own gap. */
+   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. */
 .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 under a .field caption. The box mirrors field.css's
+   multiline text beside 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. */
@@ -307,7 +351,7 @@ img.avatar {
    text sitting at different heights. Baseline lines up the text itself. */
 .detail-grid {
   display: grid;
-  grid-template-columns: 6rem 1fr;
+  grid-template-columns: var(--type-column) 1fr;
   row-gap: var(--gl-space-2xs);
   column-gap: var(--gl-space-s);
   align-items: baseline;
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index ac2f85c..07ec27c 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -747,7 +747,7 @@ class AdminContactsPagesTest < Minitest::Test
   ## Editing phones
 
   # The phone rows: one value field per line, named by the line's
-  # digest — the address the save substitutes — under the type's own
+  # digest — the address the save substitutes — beside the type's own
   # caption, and one add-row beneath them.
   def test_the_edit_screen_renders_a_digest_named_field_per_phone
     with_contacts({"ada" => ADA}) do |store|
@@ -755,7 +755,7 @@ class AdminContactsPagesTest < Minitest::Test
 
       digest = store.contact("ada").phones.first.line.digest
       body = last_response.body
-      assert_includes body, '<label class="field">mobile<input type="tel" name="phone[' + digest + ']" value="+1-555-0100"></label>'
+      assert_includes body, '<label class="field"><span>mobile</span><input type="tel" name="phone[' + digest + ']" value="+1-555-0100"></label>'
       assert_includes body, '<input type="tel" name="new_phone">'
     end
   end