admin: cut the change log etag down to a short hash
Sixty-four digits of hex wrapped two lines wide,
so the log shows twelve and keeps the rest inside.
The quotes were the wire talking, not the page,
and the whole hash waits in the title of the abbr.

Assisted-by: Claude Opus 5 via Claude Code
change trvrrmxynskkzmktlmuwxutmkknowoop
commit 46a3330e329ab3c1929c6db2cce00de613558f3e
author Alpha Chen <alpha@kejadlen.dev>
date
parent smqqpnxm
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 6a0b1aa..695e4c5 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -226,7 +226,17 @@ module ProTacts
           dt(class: "type-label") { change.action }
           dd(class: "type-body-sm") do
             div { Format.stamp(change.created_at) }
-            div(class: "type-mono") { change.etag } if change.etag
+            # <abbr>, because a cut hash is an abbreviation of one and
+            # the title is where the whole value stays reachable. A
+            # tooltip is hover's, so it is out of reach on a touch
+            # screen — the reason the shortened form has to be enough
+            # to tell two writes apart on its own, and the full etag a
+            # thing to reach for rather than to read.
+            if change.etag
+              abbr(class: "type-mono", title: Format.etag_digest(change.etag)) do
+                Format.short_etag(change.etag)
+              end
+            end
             diff_lines(change.diff)
           end
         end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 813086a..ec93e61 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -43,6 +43,35 @@ module ProTacts
         birthday&.to_s || property.value
       end
 
+      # How much of an etag's hash is shown before it is cut. Twelve
+      # hex digits is git's own longest short-hash, and this address
+      # book will never hold enough cards for two to collide in a
+      # reader's eye.
+      ETAG_DIGITS = 12 #: Integer
+
+      # An etag's hash, without the quotes an entity-tag is spelled
+      # with (Contact.etag_for): those are the wire's punctuation, and
+      # a screen showing a card's history is not the wire. Anything
+      # not shaped like an entity-tag comes back untouched — there is
+      # no second spelling to guess at.
+      #: (String etag) -> String
+      def self.etag_digest(etag)
+        etag[/\A"(.*)"\z/, 1] || etag
+      end
+
+      # The hash cut down for display. Whole, it is 64 digits, which
+      # wraps over two lines in a record's value column and reads as a
+      # wall either way; the view hands the whole of it to the title
+      # of the <abbr> this sits in, so the cut is a display and not a
+      # loss.
+      #: (String etag) -> String
+      def self.short_etag(etag)
+        digest = etag_digest(etag)
+        return digest if digest.length <= ETAG_DIGITS
+
+        "#{digest[0, ETAG_DIGITS]}…"
+      end
+
       # The other way to render one of those stamps: as itself, minus
       # the milliseconds. What the change log records happened at a
       # moment someone may need to line up against a client's own logs,
diff --git a/public/admin.css b/public/admin.css
index 74f686a..56c68bc 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -583,16 +583,19 @@ img.avatar {
 /* The change log in the third card, on the record's own grid so the
    two read as one page (ContactsShow#change_rows). The margin is the
    pre's above, for the same reason: a summary and the block it
-   discloses are one line apart, not a card's gap. An etag is 64 hex
-   digits and a pair of quotes, which is wider than the value column
-   on a phone and the one thing here that must not be shortened — so
-   it breaks mid-hash rather than pushing the card sideways, the whole
-   value on the screen either way. */
+   discloses are one line apart, not a card's gap. */
 .change-log {
   margin-top: var(--gl-space-2xs);
 }
-.change-log dd > .type-mono {
-  overflow-wrap: anywhere;
+/* The etag, cut to a short hash (Format.short_etag) with the whole of
+   it in the title. Dotted rather than the solid underline a link
+   wears, and the help cursor over it: the mark has to say there is
+   more here without saying this navigates, since it does not. Both
+   are spelled out rather than left to the browser, whose defaults for
+   abbr[title] differ on each of them. */
+.change-log abbr {
+  cursor: help;
+  text-decoration: underline dotted;
 }
 
 /* The lines a write moved, under the etag they hashed to. Removed
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 33533a8..45baab0 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -454,9 +454,6 @@ class AdminContactsPagesTest < Minitest::Test
       body = last_response.body
       assert_includes body, '<details><summary class="type-label">change log</summary>'
       assert_includes body, '<dt class="type-label">put</dt>'
-      # An entity-tag's quotes are part of it (Contact.etag_for), so
-      # they reach the page as the escape Phlex writes them with.
-      assert_includes body, store.contact("ada").etag.gsub('"', "&quot;")
       assert_includes body, store.changes.last.created_at.sub(/\.\d+Z\z/, "Z")
       refute_includes body, "<details open"
     end
@@ -478,6 +475,21 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The hash is cut to a readable length and the whole of it rides the
+  # abbr's title, so the value stays reachable without a wall of hex
+  # in the value column. The quotes an entity-tag is spelled with are
+  # the wire's and do not come to the page.
+  def test_the_change_log_cuts_the_etag_down_and_keeps_it_whole
+    with_contacts({"ada" => ADA}) do |store|
+      get "/contacts/ada"
+
+      digest = store.contact("ada").etag.delete('"')
+      assert_includes last_response.body,
+                      %(<abbr class="type-mono" title="#{digest}">#{digest[0, 12]}…</abbr>)
+      refute_includes last_response.body, "&quot;#{digest}"
+    end
+  end
+
   # Each entry shows the lines its write moved, removed above added
   # and signed rather than only colored.
   def test_the_change_log_shows_what_each_write_moved
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index 8d5dad1..a42375e 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -137,6 +137,16 @@ class FormatTest < Minitest::Test
     assert_nil ProTacts::Admin::Format.birthday(contact(bare))
   end
 
+  def test_an_etag_shows_as_a_short_bare_hash
+    etag = %("#{"ab12cd34ef56" * 5}")
+
+    assert_equal "ab12cd34ef56…", ProTacts::Admin::Format.short_etag(etag)
+  end
+
+  def test_a_hash_no_longer_than_the_cut_keeps_every_digit
+    assert_equal "ab12cd34", ProTacts::Admin::Format.short_etag(%("ab12cd34"))
+  end
+
   def test_a_stamp_drops_the_milliseconds_and_nothing_else
     assert_equal "2026-09-04T20:35:22Z",
                  ProTacts::Admin::Format.stamp("2026-09-04T20:35:22.481Z")