Fix · mobile copy button regression — hide button, make pre tap-to-copy

The previous mobile fix (display: block + width: 100% on the button)
overflowed the frame because .code has white-space: nowrap and the
button rendered inline-flex on the same nowrap line as the command,
spilling past the right edge.

Cleaner pattern, matching what gh repo clone / npmjs / Vercel
install panes do on touch devices:

- Hide .code__copy entirely below 45rem.
- Bind a click handler on the whole [data-copy-source] pre so the
  command area itself is tap-to-copy. cursor: pointer signals it.
- Add a transient "Copied ✓" badge inside the pre on success
  (anchored top/right/centred-vertically) — replaces the button's
  label-swap on mobile. Animates in and clears after 2.2 s.
- Restore the original .code flex + align-items: center so the
  command sits vertically centred in the frame regardless of height.

JS:
- Refactor attachCopyButtons to share a copyFromSource helper.
- Source binding uses data-copy-bound; button binding uses
  data-copy-btn-bound (separate flags so re-attached cloned heroes
  don't double-bind).
- Button click stops propagation so a single tap on the visible
  button (desktop) doesn't fire both the button and source handlers.
- Both surfaces flash data-state="copied" together so CSS feedback
  works on whichever surface is visible at the active breakpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Youssef
2026-05-07 19:07:01 +01:00
co-authored by Claude Opus 4.7
parent e27a02f981
commit ad3d07e507
2 changed files with 94 additions and 46 deletions
+39 -17
View File
@@ -802,25 +802,47 @@
.code__copy[data-state="copied"] .code__copy-default { display: none; }
.code__copy[data-state="copied"] .code__copy-done { display: inline; }
/* Mobile · stack the copy button beneath the command instead of
absolute-positioning over it. On coarse-pointer devices the button
is always visible (no hover state); overlaying the right edge of a
long command makes the text unreadable. Pattern from GitHub /
Vercel / npmjs / MDN — command on its own line with horizontal
scroll, copy button as a full-width tap target underneath. */
/* Mobile · drop the copy button entirely and make the whole pre
tap-to-copy. The button at narrow widths overflowed the frame; on
coarse-pointer devices a full-width tap target is the right
pattern (matches `gh repo clone` / npmjs / Vercel install panes).
The pre keeps its flex + center alignment so the command sits
vertically centred regardless of frame height. */
@media (max-width: 45rem) {
.code {
display: block;
padding-block: var(--space-sm) var(--space-sm);
.code__copy { display: none; }
.code[data-copy-source] { cursor: pointer; }
/* Transient "Copied ✓" badge inside the pre, anchored top-right.
Replaces the button's label-swap on mobile. Fades automatically
when JS removes data-state after 2.2 s. */
.code[data-copy-source][data-state="copied"] {
position: relative;
}
.code .prompt { margin-inline-end: var(--space-2xs); }
.code__copy {
position: static;
transform: none;
width: 100%;
margin-block-start: var(--space-sm);
justify-content: center;
min-height: 32px;
.code[data-copy-source][data-state="copied"]::after {
content: "Copied ✓";
position: absolute;
top: 50%;
right: var(--space-sm);
transform: translateY(-50%);
padding: 0.25rem 0.55rem;
font-family: var(--font-label);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-accent);
background: var(--color-paper);
border: var(--rule-hair) solid var(--color-accent);
border-radius: var(--radius-input, 0);
pointer-events: none;
animation: copy-flash var(--dur-short) var(--ease-out);
}
@keyframes copy-flash {
from { opacity: 0; transform: translateY(-50%) scale(0.96); }
to { opacity: 1; transform: translateY(-50%) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.code[data-copy-source][data-state="copied"]::after { animation: none; }
}
}
+55 -29
View File
@@ -647,37 +647,63 @@ function swapArchetypes(theme) {
attachCopyButtons(slotEls.hero);
}
/* — Copy-to-clipboard (silent success, label swap pattern) ————— */
/* Copy-to-clipboard (silent success, label swap pattern)
Two click surfaces:
- The Copy button (visible on desktop) explicit affordance.
- The whole pre[data-copy-source] falls back to a tappable area
on mobile where the button is hidden by CSS.
Both call the same async copy + state-flash routine. We bind once
per element via `data-copy-bound` so re-attached templates don't
double-bind. */
async function copyFromSource(source) {
if (!source) return;
const textNode = source.querySelector("[data-copy-text]");
const text = textNode ? textNode.textContent.trim() : "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch (err) {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) { }
document.body.removeChild(ta);
}
// Flash both the source and any visible button so the right
// surface (mobile pseudo-element vs desktop button label) animates.
source.dataset.state = "copied";
source.setAttribute("aria-live", "polite");
const btn = source.querySelector("[data-copy-btn]");
if (btn) btn.dataset.state = "copied";
clearTimeout(source._copyTimer);
source._copyTimer = setTimeout(() => {
delete source.dataset.state;
if (btn) delete btn.dataset.state;
}, 2200);
}
function attachCopyButtons(scope = document) {
const btns = scope.querySelectorAll("[data-copy-btn]:not([data-copy-bound])");
// Bind the whole pre — works on mobile where the button is hidden.
const sources = scope.querySelectorAll("[data-copy-source]:not([data-copy-bound])");
sources.forEach((source) => {
source.dataset.copyBound = "true";
source.addEventListener("click", () => copyFromSource(source));
});
// Button click is also handled — stop propagation so the source
// listener doesn't double-fire (single copy per click).
const btns = scope.querySelectorAll("[data-copy-btn]:not([data-copy-btn-bound])");
btns.forEach((btn) => {
btn.dataset.copyBound = "true";
btn.addEventListener("click", async () => {
const source = btn.closest("[data-copy-source]");
const textNode = source && source.querySelector("[data-copy-text]");
const text = textNode ? textNode.textContent.trim() : "";
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch (err) {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) { }
document.body.removeChild(ta);
}
btn.dataset.state = "copied";
btn.setAttribute("aria-live", "polite");
clearTimeout(btn._copyTimer);
btn._copyTimer = setTimeout(() => {
delete btn.dataset.state;
}, 2200);
btn.dataset.copyBtnBound = "true";
btn.addEventListener("click", (e) => {
e.stopPropagation();
copyFromSource(btn.closest("[data-copy-source]"));
});
});
}