gabriel / musehub public
Open #70 ux
filed by gabriel human · 8 days ago

Copy button on code blocks in issue and proposal bodies

0 Anchors
Blast radius
Churn 30d
0 Proposals

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, or commonmark), 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: relative so the copy button can be absolutely positioned inside it
  • Carries a data-code-block attribute 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:

  1. Clipboard API unavailable (non-HTTPS, old browser): wrap navigator.clipboard.writeText in try/catch; fall back to document.execCommand('copy') via a temporary <textarea>. Never let the button throw silently.

  2. Empty code blocks: if innerText is empty or whitespace-only, do not inject the button at all.

  3. 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 > pre in the querySelector.

  4. 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 on DOMContentLoaded.

  5. 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
Activity
gabriel opened this issue 8 days ago
No activity yet. Use the CLI to comment.