admin: a nameless group's id stops passing for a name
A group with no name was shown by its id in the name's own type,
so it looked like someone had named the group "3f9a".
GroupLabel now sets that id in mono, quieter, at regular weight,
wherever a group is named: headings, rows, tags, and badges.

Assisted-by: Claude Opus 5 via Claude Code
change ukwlwqvmyzrwoyoyztkzsoqtqxqrvsxt
commit 63cb5a04be6f7e6bd99d1d57fda320cf5d971b4c
author Alpha Chen <alpha@kejadlen.dev>
date
parent mqoxynnu
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index e45173f..e0bd151 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -3,6 +3,7 @@ require "pro_tacts/admin/phlex"
 require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/contact_dialog"
 require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
 require "pro_tacts/admin/upcoming_birthdays"
 
@@ -117,7 +118,7 @@ module ProTacts
           @groups.each do |group|
             li do
               a(href: "/groups/#{group.id}") do
-                div(style: "flex: 1; min-width: 0; font-weight: 550;") { group.label }
+                div(style: "flex: 1; min-width: 0; font-weight: 550;") { render GroupLabel.new(group:) }
               end
             end
           end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 576fbfb..a870901 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -2,6 +2,7 @@ require "pro_tacts/admin/phlex"
 
 require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
 
 module ProTacts
@@ -159,7 +160,7 @@ module ProTacts
         dt(class: "type-label") { "groups" }
         dd(class: "type-body-sm") do
           div(class: "tag-set") do
-            @groups.each { |group| a(href: "/groups/#{group.id}", class: "tag") { group.label } }
+            @groups.each { |group| a(href: "/groups/#{group.id}", class: "tag") { render GroupLabel.new(group:) } }
           end
         end
       end
@@ -182,10 +183,19 @@ module ProTacts
           # value column (admin.css) — under the value it could be
           # read as marking the row below. No tone: the accent is held
           # constant across a view, and inherited is not a status.
-          span(class: "badge") { group } if group
+          span(class: "badge") { group_mark(group) } if group
         end
       end
 
+      # A lent row knows its group only by label (Contact#group_of), and
+      # a nameless group's label is its id — so the id is looked up among
+      # the contact's own groups to mark it the way GroupLabel does.
+      #: (String label) -> void
+      def group_mark(label)
+        nameless = @groups.find { it.name.nil? && it.id == label }
+        nameless ? render(GroupLabel.new(group: nameless)) : plain(label)
+      end
+
       #: (String | Array[String] value) -> void
       def render_value(value)
         if value.is_a?(Array)
diff --git a/lib/pro_tacts/admin/group_label.rb b/lib/pro_tacts/admin/group_label.rb
new file mode 100644
index 0000000..a63378d
--- /dev/null
+++ b/lib/pro_tacts/admin/group_label.rb
@@ -0,0 +1,27 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+  module Admin
+    # A group's name wherever one is shown. A group needs no name
+    # (db/migrations/005_group_identity.rb), and one without is shown by
+    # its id, marked `data-nameless` so admin.css can set it as an
+    # identifier rather than let it pass for a name.
+    class GroupLabel < Phlex::HTML
+      # @rbs @group: Store::Group
+
+      #: (group: Store::Group) -> void
+      def initialize(group:)
+        @group = group
+      end
+
+      def view_template
+        name = @group.name
+        if name
+          plain name
+        else
+          span(data: {nameless: true}) { @group.id }
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 002dd97..c45aff0 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -1,6 +1,7 @@
 require "pro_tacts/admin/phlex"
 
 require "pro_tacts/admin/contacts_edit"
+require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
 
 module ProTacts
@@ -38,7 +39,10 @@ module ProTacts
           # 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") { "‹ #{@group.label}" }
+              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
diff --git a/lib/pro_tacts/admin/groups_index.rb b/lib/pro_tacts/admin/groups_index.rb
index aaae82c..7039b2a 100644
--- a/lib/pro_tacts/admin/groups_index.rb
+++ b/lib/pro_tacts/admin/groups_index.rb
@@ -1,5 +1,6 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
 
 module ProTacts
@@ -35,7 +36,7 @@ module ProTacts
                 @groups.each do |group|
                   li do
                     a(href: "/groups/#{group.id}") do
-                      div(style: "flex: 1; min-width: 0; font-weight: 550;") { group.label }
+                      div(style: "flex: 1; min-width: 0; font-weight: 550;") { render GroupLabel.new(group:) }
                       span(class: "type-label") { members_label(group) }
                     end
                   end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 26db87b..587f4c7 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -1,6 +1,7 @@
 require "pro_tacts/admin/phlex"
 
 require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
 
 module ProTacts
@@ -33,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;") { @group.label }
+                  h1(class: "type-h2", style: "margin: 0;") { render GroupLabel.new(group: @group) }
                 end
                 # Never empty, unlike ContactsShow's: the members row
                 # always renders (#members_row).
diff --git a/public/admin.css b/public/admin.css
index 43ed65c..fb17ead 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -579,6 +579,16 @@ img.avatar {
 .detail-grid dd:has(> a.type-label) > div {
   grid-column: 1;
 }
+/* A nameless group shown by its id (Admin::GroupLabel): set in mono,
+   a step quieter, and at regular weight even inside a bold heading or
+   row, so it reads as an identifier standing in for a name rather than
+   as the name itself. */
+[data-nameless] {
+  font-family: var(--gl-font-mono);
+  font-weight: 400;
+  color: var(--gl-color-text-secondary);
+}
+
 /* A row's action — a group card's "edit members" — takes the badge's
    place at the row's right edge. Unlike the badge it is plain text,
    so it has a real baseline to share with the tags beside it. */
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 416d073..4d67830 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -308,9 +308,9 @@ class AdminContactsPagesTest < Minitest::Test
       nameless = FixtureData.seed_group(store, members: ["ada"], lines: [])
 
       get "/contacts/ada"
-      tags = last_response.body.scan(%r{<a href="/groups/([^"]*)" class="tag">([^<]*)</a>})
+      tags = last_response.body.scan(%r{<a href="/groups/([^"]*)" class="tag">(.*?)</a>})
 
-      assert_equal({named => "Booles", nameless => nameless}.sort, tags)
+      assert_equal({named => "Booles", nameless => %(<span data-nameless>#{nameless}</span>)}.sort, tags)
       # The grid leads with what the contact belongs to, so the values
       # under it are read already knowing whose they might be.
       assert_equal "groups", last_response.body[%r{<dt class="type-label">([^<]*)<}, 1]
@@ -335,9 +335,9 @@ class AdminContactsPagesTest < Minitest::Test
       id = FixtureData.seed_group(store, members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada"
-      marks = last_response.body.scan(%r{<span class="badge">([^<]*)</span>})
+      marks = last_response.body.scan(%r{<span class="badge">(.*?)</span></dd>})
 
-      assert_equal [[id], [id]], marks
+      assert_equal [[%(<span data-nameless>#{id}</span>)]] * 2, marks
     end
   end