diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md
index 6d18e1f..190164b 100644
--- a/docs/tickets/mps-24.md
+++ b/docs/tickets/mps-24.md
@@ -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 —
diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css
index a736f20..65802f4 100644
--- a/make_post_sell/static/css/common.css
+++ b/make_post_sell/static/css/common.css
@@ -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;
diff --git a/make_post_sell/static/js/copy.js b/make_post_sell/static/js/copy.js
new file mode 100644
index 0000000..0e07a7b
--- /dev/null
+++ b/make_post_sell/static/js/copy.js
@@ -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:
+ *
+ *
+ * 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
+ );
+})();
diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2
index ddb5fdd..a0a0d72 100644
--- a/make_post_sell/templates/content.j2
+++ b/make_post_sell/templates/content.j2
@@ -147,8 +147,15 @@
they see and confirm provenance. #}
{% set fc = product.checksums %}
{% set tc = product.content_checksums() %}
+ {%- macro hashcell(value, algo) -%}
+
+ {%- endmacro -%}
Click any hash to copy it.Checksums — page report
+
{% for label, src in [
@@ -157,8 +164,8 @@
("Title", tc.get("title", {})),
("Description", tc.get("description", {})),
] %}
- {% if src.get("sha256") %}Asset Algorithm Hash {% endif %}
- {% if src.get("md5") %}{{ label }} SHA-256 {{ src["sha256"] }} {% endif %}
+ {% if src.get("sha256") %}{% if not src.get("sha256") %}{{ label }}{% endif %} MD5 {{ src["md5"] }} {% endif %}
+ {% if src.get("md5") %}{{ label }} SHA-256 {{ hashcell(src["sha256"], "SHA-256") }} {% endif %}
{% endfor %}
{% if not src.get("sha256") %}{{ label }}{% endif %} MD5 {{ hashcell(src["md5"], "MD5") }}
+ Generic one-click copy (static/js/copy.js, delegated
+ on [data-copy]). Capability-driven: without JS the
+ value is still visible & selectable; JS adds the click +
+ "Copied!" feedback. Used by the checksum page report on
+ content.j2. Theme-aware tokens (works in dark mode).
+
| Asset | Algorithm | Hash |
|---|---|---|
| Title | SHA-256 | + + |
| MD5 | + + |