Copy button on code blocks in issue and proposal bodies
Overview
When hovering over a fenced code block in an issue or proposal body, a copy icon should appear in the top-right corner of the block. Clicking it copies the code to the clipboard. This is standard behavior in GitHub, Notion, and most modern markdown renderers — its absence creates friction whenever a comment contains a command, snippet, or config that the reader needs to run.
Phase 1 — Audit the current markdown rendering pipeline
Before writing any code, answer these questions:
- How is markdown rendered on issue and proposal detail pages? Is it server-side (e.g. a Jinja filter using
mistune,markdown, orcommonmark), or client-side JS? - What HTML does a fenced code block currently produce? Does it render as
<pre><code>,<pre class="...">, or something else? - Is there already any syntax highlighting library in use (e.g. Prism, highlight.js, Pygments)? If so, does it wrap blocks in a container we can target?
- Do issue bodies and proposal bodies share the same rendering template/filter, or are they separate?
- Is there existing TypeScript in the bundle that handles clipboard operations anywhere?
Run:
muse -C ~/ecosystem/musehub code grep "markdown" --json
muse -C ~/ecosystem/musehub code grep "highlight" --json
muse -C ~/ecosystem/musehub content-grep "<pre" --json
Produce a written audit. Do not write any code in this phase.
Phase 2 — Wrap code blocks in a positioned container
Every fenced code block needs a wrapper element that:
- Has
position: relativeso the copy button can be absolutely positioned inside it - Carries a
data-code-blockattribute the JS selector can target unambiguously - Does not break existing layout, padding, or syntax highlighting
If rendering is server-side, add the wrapper in the markdown filter:
# example — adapt to whichever markdown lib is in use
import re
def add_code_block_wrappers(html: str) -> str:
return re.sub(
r'(<pre[^>]*>)',
r'<div class="code-block-wrap" data-code-block>\1',
html
).replace('</pre>', '</pre></div>')
If rendering is client-side, do it in the TypeScript initialisation step (Phase 3).
Rules:
- Wrapper must survive template refactors — key on the
<pre>element, not on a class name that could be renamed - Must not introduce a second scroll container — the wrapper must never have
overflow: auto; that belongs on<pre>itself
Phase 3 — Button markup and TypeScript initialisation
On DOMContentLoaded, find every [data-code-block] and inject the copy button:
function initCodeCopyButtons(): void {
document.querySelectorAll<HTMLElement>('[data-code-block]').forEach(wrap => {
const pre = wrap.querySelector('pre');
if (!pre || wrap.querySelector('.code-copy-btn')) return; // idempotent
const btn = document.createElement('button');
btn.className = 'code-copy-btn';
btn.setAttribute('aria-label', 'Copy code');
btn.innerHTML = copyIcon(); // inline SVG — no external request
wrap.appendChild(btn);
btn.addEventListener('click', async () => {
const code = pre.querySelector('code')?.innerText ?? pre.innerText;
await navigator.clipboard.writeText(code);
btn.classList.add('code-copy-btn--copied');
btn.setAttribute('aria-label', 'Copied!');
setTimeout(() => {
btn.classList.remove('code-copy-btn--copied');
btn.setAttribute('aria-label', 'Copy code');
}, 1500);
});
});
}
document.addEventListener('DOMContentLoaded', initCodeCopyButtons);
copyIcon() returns the same inline SVG used elsewhere in the codebase (check icon() helper). Do not add a network request for the icon.
Phase 4 — SCSS: button appearance and hover reveal
The button is invisible at rest and fades in on [data-code-block]:hover. The copied state swaps the icon color.
.code-block-wrap {
position: relative;
}
.code-copy-btn {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border-subtle);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease;
// Reveal on parent hover or button focus
[data-code-block]:hover &,
&:focus-visible {
opacity: 1;
}
&:hover {
color: var(--text-primary);
border-color: var(--border-default);
}
// Copied state — accent color feedback, no toast
&--copied {
opacity: 1;
color: var(--color-accent);
border-color: var(--color-accent);
}
}
After editing SCSS, compile:
npx sass src/scss/app.scss musehub/templates/musehub/static/app.css --style=compressed --no-source-map
Phase 5 — Edge cases and hardening
Handle these before shipping:
Clipboard API unavailable (non-HTTPS, old browser): wrap
navigator.clipboard.writeTextin try/catch; fall back todocument.execCommand('copy')via a temporary<textarea>. Never let the button throw silently.Empty code blocks: if
innerTextis empty or whitespace-only, do not inject the button at all.Nested code blocks:
[data-code-block]should never be nested. If the markdown renderer produces<pre><pre>, the outer wrapper is the target — guard with:scope > prein the querySelector.Dynamic content (if issues support live comment appending without full page reload): call
initCodeCopyButtons()after any DOM insertion that adds new comment bodies, not just onDOMContentLoaded.Mobile: on touch devices there is no hover state. The button should be permanently visible (opacity: 1) on screens narrower than the breakpoint where hover is unavailable:
@media (hover: none) {
.code-copy-btn {
opacity: 1;
}
}
Phase 6 — Scope check: all surfaces that render markdown
Confirm the copy button works on every surface that renders user-authored markdown:
[ ] Issue body
[ ] Issue comments
[ ] Proposal body
[ ] Proposal comments
[ ] Any preview pane (if a markdown preview is rendered before submission)
If any surface uses a different rendering path found in Phase 1, the wrapper must be applied there too — no surface left behind.
Acceptance criteria
[ ] Hovering a code block reveals a copy button in the top-right corner
[ ] Clicking the button copies the exact code text to the clipboard
[ ] Button turns accent color for 1.5s after copy — no toast, no modal
[ ] Button is invisible at rest and does not affect layout
[ ] Works on mobile (permanently visible via @media hover:none)
[ ] Clipboard fallback handles non-HTTPS / old browsers without throwing
[ ] Empty code blocks do not get a button
[ ] Works on issue body, issue comments, proposal body, proposal comments
[ ] SCSS source edited — never app.css directly
[ ] All TypeScript in .ts source files — never compiled .js directly