feat: MPS-24 — click-to-copy checksum hashes + styled report table

Operator: make the checksum report hashes click-to-copy (they were
unstyled and overflowing the column).

- New reusable static/js/copy.js: delegated [data-copy] handler, async
  Clipboard API + hidden-textarea/execCommand fallback, transient
  'Copied!' feedback. Generic (not checksum-specific) so other ad-hoc
  inline copy buttons can migrate later. Cache-busted (?v=git_hash).
- content.j2: each hash wrapped in button.copy-hash via a local Jinja
  macro (DRY). Capability-driven: no-JS the hash text is still visible
  + selectable.
- New .checksum-table / .copy-hash CSS: table-layout:fixed +
  word-break:break-all so the 64-char SHA wraps fully visible instead
  of truncating; theme-aware tokens only (dark-mode correct, no
  [data-theme] override that could regress like the trans-blue bug).
- /styleguide#copyhash added. Test:
  test_checksum_report_is_click_to_copy. 1151 passed.
- Docs: mps-24.md Phase 2.8q.
This commit is contained in:
russell@unturf.com 2026-05-18 09:10:34 -04:00
parent 52e6c57145
commit 5888848999
No known key found for this signature in database
6 changed files with 229 additions and 2 deletions

View file

@ -363,6 +363,19 @@ Tests (`test_functional.py::TestProductTagsSpa`):
Deferred (occasional click, not the hot path): AJAX-ifying the
"Suggest categories" link — still a full navigation by design.
**Phase 2.8q — click-to-copy hashes + styled checksum table** (shipped
2026-05-18): operator: make the checksum hashes click-to-copy (they
were unstyled, overflowing the column). New reusable
`static/js/copy.js` (delegated `[data-copy]`, async Clipboard API +
hidden-textarea fallback, "Copied!" feedback, cache-busted) — generic,
not checksum-specific. `content.j2` wraps each hash in a
`button.copy-hash` (local Jinja macro, DRY). New `.checksum-table` /
`.copy-hash` CSS: `table-layout:fixed` + `word-break:break-all` so the
64-char SHA wraps fully visible instead of truncating; theme-aware
tokens only (no `[data-theme]` overrides → can't re-introduce the
trans-blue-class bug). `/styleguide#copyhash` added. Test:
`test_checksum_report_is_click_to_copy`.
**Phase 2.8p — Checksums as a verifiable page report** (shipped
2026-05-17): operator wants the content page's "Checksums" panel to
cover the whole page, not just the product file —

View file

@ -711,6 +711,81 @@ div.product-images img.product-main {
}
/* Prevent content sections from overflowing */
/* MPS-24: Checksums page report + click-to-copy hashes. Theme-aware
tokens only (they carry dark values) so no [data-theme] overrides
needed. Table layout so the 64-char SHA wraps fully instead of
overflowing the column. */
details.product-checksums {
margin: var(--space-3, 12px) 0;
}
details.product-checksums > summary {
cursor: pointer;
font-weight: 600;
}
p.checksum-hint {
margin: var(--space-2, 8px) 0;
color: var(--text-muted, #6b7280);
}
table.checksum-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
margin: 0 0 var(--space-3, 12px) 0;
}
table.checksum-table th,
table.checksum-table td {
text-align: left;
vertical-align: top;
padding: var(--space-2, 8px) var(--space-3, 12px);
border-bottom: 1px solid var(--border-light, #e5e7eb);
}
table.checksum-table th {
color: var(--text-muted, #6b7280);
font-size: var(--type-body-sm-size, 0.875rem);
}
table.checksum-table th:first-child,
table.checksum-table td:first-child { width: 7.5rem; }
table.checksum-table th:nth-child(2),
table.checksum-table td:nth-child(2) { width: 5.5rem; }
button.copy-hash {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-2, 8px);
width: 100%;
margin: 0;
padding: var(--space-1, 4px) var(--space-2, 8px);
border: 1px solid var(--border-light, #e5e7eb);
border-radius: var(--radius-sm, 4px);
background: var(--surface-dim, #f9f9fa);
color: var(--text-body, #333);
font-family: var(--font-mono, ui-monospace, Menlo, Consolas, monospace);
font-size: var(--type-body-sm-size, 0.8125rem);
text-align: left;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
}
button.copy-hash > code {
word-break: break-all;
background: none;
padding: 0;
}
button.copy-hash:hover,
button.copy-hash:focus-visible {
background: var(--surface-container, #eee);
border-color: var(--border-default, #d1d5db);
}
button.copy-hash.copied {
border-color: var(--color-green, #4d7a1f);
}
span.copy-hash-status {
color: var(--color-green, #4d7a1f);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: var(--type-body-sm-size, 0.75rem);
font-weight: 600;
white-space: nowrap;
}
.product-description {
max-width: 100%;
overflow-x: auto;

View file

@ -0,0 +1,71 @@
/* copy.js — generic click-to-copy for any [data-copy] element.
*
* Capability-driven (CLAUDE.md): without JS the element's text is still
* visible and selectable, so the value is never lost — JS only adds the
* one-click convenience + "Copied!" feedback.
*
* Usage:
* <button type="button" data-copy="VALUE">
* <code>VALUE</code><span class="copy-hash-status"></span>
* </button>
*
* Delegated listener so elements rendered later still work. Uses the
* async Clipboard API where available, with a hidden-textarea +
* execCommand fallback for older / non-secure contexts.
*/
(function () {
"use strict";
function flash(el, msg) {
var s = el.querySelector(".copy-hash-status");
if (s) s.textContent = msg;
el.classList.add("copied");
clearTimeout(el.__copyTimer);
el.__copyTimer = setTimeout(function () {
if (s) s.textContent = "";
el.classList.remove("copied");
}, 1600);
}
function legacyCopy(text) {
try {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "absolute";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch (e) {
return false;
}
}
function copy(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).then(
function () { return true; },
function () { return legacyCopy(text); }
);
}
return Promise.resolve(legacyCopy(text));
}
document.addEventListener(
"click",
function (ev) {
var el = ev.target.closest("[data-copy]");
if (!el) return;
ev.preventDefault();
var text = el.getAttribute("data-copy") || "";
if (!text) return;
copy(text).then(function (ok) {
flash(el, ok ? "Copied!" : "Press Ctrl+C");
});
},
false
);
})();

View file

@ -147,8 +147,15 @@
they see and confirm provenance. #}
{% set fc = product.checksums %}
{% set tc = product.content_checksums() %}
{%- macro hashcell(value, algo) -%}
<button type="button" class="copy-hash" data-copy="{{ value }}"
title="Click to copy {{ algo }}" aria-label="Copy {{ algo }} hash">
<code>{{ value }}</code><span class="copy-hash-status" aria-hidden="true"></span>
</button>
{%- endmacro -%}
<details class="product-checksums">
<summary>Checksums &mdash; page report</summary>
<p class="type-body-sm checksum-hint">Click any hash to copy it.</p>
<table class="checksum-table">
<tr><th>Asset</th><th>Algorithm</th><th>Hash</th></tr>
{% for label, src in [
@ -157,8 +164,8 @@
("Title", tc.get("title", {})),
("Description", tc.get("description", {})),
] %}
{% if src.get("sha256") %}<tr><td>{{ label }}</td><td>SHA-256</td><td><code>{{ src["sha256"] }}</code></td></tr>{% endif %}
{% if src.get("md5") %}<tr><td>{% if not src.get("sha256") %}{{ label }}{% endif %}</td><td>MD5</td><td><code>{{ src["md5"] }}</code></td></tr>{% endif %}
{% if src.get("sha256") %}<tr><td>{{ label }}</td><td>SHA-256</td><td>{{ hashcell(src["sha256"], "SHA-256") }}</td></tr>{% endif %}
{% if src.get("md5") %}<tr><td>{% if not src.get("sha256") %}{{ label }}{% endif %}</td><td>MD5</td><td>{{ hashcell(src["md5"], "MD5") }}</td></tr>{% endif %}
{% endfor %}
</table>
</details>
@ -219,5 +226,6 @@ function playInline(container, videoUrl) {
<script src="/static/js/watch.js?v={{ request.git_hash }}"></script>
{% endif %}
<script src="/static/js/signals.js?v={{ request.git_hash }}"></script>
<script src="/static/js/copy.js?v={{ request.git_hash }}" defer></script>
{%- endblock -%}

View file

@ -194,6 +194,7 @@
<a href="#comments">Comments</a>
<a href="#tagchips">Tag Chips</a>
<a href="#serprail">SERP Rail</a>
<a href="#copyhash">Click to Copy</a>
<a href="#toggle">Toggle</a>
<a href="#ribbon">Ribbon</a>
<a href="#taskbar">Task Bar</a>
@ -1215,6 +1216,36 @@ The snippet sets inline margin-left for depth-N replies (depth * 20px).</div>
</div>
<!-- ======== CLICK TO COPY ======== -->
<div class="sg-section" id="copyhash">
<div class="sg-section-title">Click to Copy</div>
<p style="max-width:600px;line-height:1.6;">
Generic one-click copy (<code>static/js/copy.js</code>, delegated
on <code>[data-copy]</code>). Capability-driven: without JS the
value is still visible &amp; selectable; JS adds the click +
"Copied!" feedback. Used by the checksum page report on
<code>content.j2</code>. Theme-aware tokens (works in dark mode).
</p>
<div class="sg-demo">
<table class="checksum-table" style="max-width:520px;">
<tr><th>Asset</th><th>Algorithm</th><th>Hash</th></tr>
<tr><td>Title</td><td>SHA-256</td><td>
<button type="button" class="copy-hash"
data-copy="4fde23aa35bb9350f14f2bdd4e88b098e03af060608fdd588">
<code>4fde23aa35bb9350f14f2bdd4e88b098e03af060608fdd588</code><span class="copy-hash-status" aria-hidden="true"></span>
</button>
</td></tr>
<tr><td></td><td>MD5</td><td>
<button type="button" class="copy-hash" data-copy="c6acda7417b71307f953d53d2164c852">
<code>c6acda7417b71307f953d53d2164c852</code><span class="copy-hash-status" aria-hidden="true"></span>
</button>
</td></tr>
</table>
<script src="/static/js/copy.js?v={{ request.git_hash }}" defer></script>
</div>
</div>
<!-- ======== TOGGLE ======== -->
<div class="sg-section" id="toggle">
<div class="sg-section-title">Toggle / Details</div>

View file

@ -3374,6 +3374,35 @@ class AuthenticatedFunctionalTests(_AuthenticatedBase):
return product_id, product_slug
def test_checksum_report_is_click_to_copy(self):
"""MPS-24: the Checksums page report renders every hash as a
click-to-copy button (data-copy) and ships the cache-busted
copy.js. Stored file/thumbnail hashes + live title/description
hashes all appear."""
product_id, slug = self._create_content_with_metadata(
"copy-hash-shop", "Hashing Title", "A description here.",
{
"extensions": {"product": "pdf", "thumbnail1": "jpg"},
"checksums": {
"product": {"sha256": "a" * 64, "md5": "b" * 32},
"thumbnail1": {"sha256": "c" * 64, "md5": "d" * 32},
},
},
)
res = self.testapp.get(f"/c/{product_id}/{slug}", status=200)
body = res.body.decode()
self.assertIn('class="copy-hash"', body)
# Stored file + thumbnail hashes are copyable.
self.assertIn('data-copy="' + "a" * 64 + '"', body)
self.assertIn('data-copy="' + "c" * 64 + '"', body)
# Live title/description hashes are present (computed on render).
import hashlib
title_sha = hashlib.sha256("Hashing Title".encode()).hexdigest()
self.assertIn('data-copy="' + title_sha + '"', body)
# Cache-busted copy.js shipped.
self.assertIn("/static/js/copy.js?v=", body)
self.assertIn("Click any hash to copy", body)
def test_watch_json_includes_karaoke_urls(self):
"""Watch JSON returns instrumentals/vocals URLs for products with karaoke tracks."""
product_id, _ = self._create_content_with_metadata(