Copy buttons on code blocks + merge Skills and Tutorials under Docs

Two small-to-medium improvements bundled together.

1. Copy buttons on every rendered code block.
   - render-markdown.js: wrap each fenced code block in a .code-block-wrap
     container and emit a <button class="code-block-copy" data-copy="...">
     alongside it. Button text is set via CSS ::before content so the
     'Copy' / 'Copied' label is a single toggle class (.is-copied).
   - render-page.js: 12-line inline script at the end of the body wires
     a delegated click handler that calls navigator.clipboard.writeText
     and flips .is-copied for 1.5s.
   - sub-pages.css: button styles matching the dark terminal palette,
     hidden until you hover the code block, accent-colored success state.

2. Merge Skills and Tutorials under a single Docs nav item.
   - Rename the Skills nav link to 'Docs' in every header (partial +
     4 hand-authored pages). Drop the separate Tutorials nav item; it
     now lives inside Docs. Anti-patterns stays as its own top-level.
   - scripts/build-sub-pages.js: replace renderSkillsSidebar and
     renderTutorialsSidebar with a unified renderDocsSidebar that shows
     every skill category followed by a Tutorials group. Takes a
     current descriptor of shape { kind: 'skill'|'tutorial', id|slug }
     so both skill detail and tutorial detail pages can mark the active
     row. activeNav on every /skills/* and /tutorials/* page is now
     'docs'; the shared site header's data-nav matches.

Verified: /skills/polish and /tutorials/getting-started both render
with the unified Docs sidebar (all 21 skills grouped by category +
both tutorials as a final group). The Docs nav item is aria-current
on both. Copy buttons appear on every fenced code block and toggle
to 'Copied' when clicked.
This commit is contained in:
Paul Bakaus
2026-04-08 10:50:23 -07:00
parent a89f7f5040
commit eb130c4af9
9 changed files with 120 additions and 56 deletions
+1 -2
View File
@@ -7,9 +7,8 @@
<div class="site-header-right">
<nav class="site-header-nav" aria-label="Primary">
<a href="/" data-nav="home">Home</a>
<a href="/skills" data-nav="skills">Skills</a>
<a href="/skills" data-nav="docs">Docs</a>
<a href="/anti-patterns" data-nav="anti-patterns">Anti-Patterns</a>
<a href="/tutorials" data-nav="tutorials">Tutorials</a>
<a href="/gallery" data-nav="gallery">Gallery</a>
</nav>
<a href="https://github.com/pbakaus/impeccable" class="site-header-github" target="_blank" rel="noopener" aria-label="Impeccable on GitHub, 17k stars">
+1 -2
View File
@@ -174,9 +174,8 @@
<div class="site-header-right">
<nav class="site-header-nav" aria-label="Primary">
<a href="/" data-nav="home">Home</a>
<a href="/skills" data-nav="skills">Skills</a>
<a href="/skills" data-nav="docs">Docs</a>
<a href="/anti-patterns" data-nav="anti-patterns">Anti-Patterns</a>
<a href="/tutorials" data-nav="tutorials">Tutorials</a>
<a href="/gallery" data-nav="gallery">Gallery</a>
</nav>
<a href="https://github.com/pbakaus/impeccable" class="site-header-github" target="_blank" rel="noopener" aria-label="Impeccable on GitHub, 17k stars">
+58 -1
View File
@@ -1312,8 +1312,16 @@ main#main {
color: var(--color-ink);
}
.prose .code-block {
.prose .code-block-wrap {
position: relative;
margin: 1.25em 0;
}
.prose .code-block-wrap .code-block {
margin: 0;
}
.prose .code-block {
padding: var(--spacing-md);
background: oklch(12% 0.005 350);
color: oklch(92% 0.005 350);
@@ -1325,6 +1333,55 @@ main#main {
border: 1px solid oklch(20% 0.005 350);
}
.code-block-copy {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 10px;
background: oklch(20% 0.005 350);
border: 1px solid oklch(30% 0.005 350);
color: oklch(78% 0.005 350);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
opacity: 0;
transition:
opacity var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out);
}
.code-block-wrap:hover .code-block-copy,
.code-block-copy:focus-visible {
opacity: 1;
}
.code-block-copy::before {
content: "Copy";
}
.code-block-copy:hover {
background: oklch(30% 0.005 350);
color: oklch(92% 0.005 350);
border-color: oklch(40% 0.005 350);
}
.code-block-copy.is-copied {
opacity: 1;
background: var(--color-accent);
color: var(--color-paper);
border-color: var(--color-accent);
}
.code-block-copy.is-copied::before {
content: "Copied";
}
.prose .code-block code {
background: transparent;
border: none;
+1 -2
View File
@@ -196,9 +196,8 @@
<div class="site-header-right">
<nav class="site-header-nav" aria-label="Primary">
<a href="/" data-nav="home">Home</a>
<a href="/skills" data-nav="skills">Skills</a>
<a href="/skills" data-nav="docs">Docs</a>
<a href="/anti-patterns" data-nav="anti-patterns">Anti-Patterns</a>
<a href="/tutorials" data-nav="tutorials">Tutorials</a>
<a href="/gallery" data-nav="gallery" aria-current="page">Gallery</a>
</nav>
<a href="https://github.com/pbakaus/impeccable" class="site-header-github" target="_blank" rel="noopener" aria-label="Impeccable on GitHub, 17k stars">
+1 -2
View File
@@ -57,9 +57,8 @@
<div class="site-header-right">
<nav class="site-header-nav" aria-label="Primary">
<a href="/" data-nav="home" aria-current="page">Home</a>
<a href="/skills" data-nav="skills">Skills</a>
<a href="/skills" data-nav="docs">Docs</a>
<a href="/anti-patterns" data-nav="anti-patterns">Anti-Patterns</a>
<a href="/tutorials" data-nav="tutorials">Tutorials</a>
<a href="/gallery" data-nav="gallery">Gallery</a>
</nav>
<a href="https://github.com/pbakaus/impeccable" class="site-header-github" target="_blank" rel="noopener" aria-label="Impeccable on GitHub, 17k stars">
+1 -2
View File
@@ -32,9 +32,8 @@
<div class="site-header-right">
<nav class="site-header-nav" aria-label="Primary">
<a href="/" data-nav="home">Home</a>
<a href="/skills" data-nav="skills">Skills</a>
<a href="/skills" data-nav="docs">Docs</a>
<a href="/anti-patterns" data-nav="anti-patterns">Anti-Patterns</a>
<a href="/tutorials" data-nav="tutorials">Tutorials</a>
<a href="/gallery" data-nav="gallery">Gallery</a>
</nav>
<a href="https://github.com/pbakaus/impeccable" class="site-header-github" target="_blank" rel="noopener" aria-label="Impeccable on GitHub, 17k stars">
+40 -44
View File
@@ -110,15 +110,19 @@ ${bodyHtml}
}
/**
* Render the left sidebar used across the /skills section.
* Shows every skill grouped by category. Pass the current skill id to
* mark it with aria-current="page".
* Render the unified Docs sidebar used across /skills and /tutorials.
* Shows every skill grouped by category, then tutorials as a final
* group. Pass the current page identifier so we can mark it:
*
* { kind: 'skill', id: 'polish' }
* { kind: 'tutorial', slug: 'getting-started' }
* null (no current page)
*/
function renderSkillsSidebar(skillsByCategory, currentSkillId = null) {
function renderDocsSidebar(skillsByCategory, tutorials, current = null) {
let html = `
<aside class="skills-sidebar" aria-label="All skills">
<aside class="skills-sidebar" aria-label="Documentation">
<div class="skills-sidebar-inner">
<p class="skills-sidebar-label">Skills</p>
<p class="skills-sidebar-label">Docs</p>
`;
for (const category of CATEGORY_ORDER) {
@@ -130,8 +134,26 @@ function renderSkillsSidebar(skillsByCategory, currentSkillId = null) {
<ul class="skills-sidebar-list">
${list
.map((s) => {
const current = s.id === currentSkillId ? ' aria-current="page"' : '';
return ` <li><a href="/skills/${s.id}"${current}>/${escapeHtml(s.id)}</a></li>`;
const isCurrent = current?.kind === 'skill' && current.id === s.id;
const attr = isCurrent ? ' aria-current="page"' : '';
return ` <li><a href="/skills/${s.id}"${attr}>/${escapeHtml(s.id)}</a></li>`;
})
.join('\n')}
</ul>
</div>
`;
}
if (tutorials && tutorials.length > 0) {
html += `
<div class="skills-sidebar-group" data-category="tutorials">
<p class="skills-sidebar-group-title">Tutorials</p>
<ul class="skills-sidebar-list">
${tutorials
.map((t) => {
const isCurrent = current?.kind === 'tutorial' && current.slug === t.slug;
const attr = isCurrent ? ' aria-current="page"' : '';
return ` <li><a href="/tutorials/${t.slug}"${attr}>${escapeHtml(t.title)}</a></li>`;
})
.join('\n')}
</ul>
@@ -292,32 +314,6 @@ function renderRuleCard(rule) {
</article>`;
}
/**
* Render the tutorials sidebar: list of tutorial pages in order, with
* the current tutorial marked via aria-current.
*/
function renderTutorialsSidebar(tutorials, currentSlug = null) {
const entries = tutorials
.map((t) => {
const current = t.slug === currentSlug ? ' aria-current="page"' : '';
return ` <li><a href="/tutorials/${t.slug}"${current}>${escapeHtml(t.title)}</a></li>`;
})
.join('\n');
return `
<aside class="skills-sidebar tutorials-sidebar" aria-label="Tutorials">
<div class="skills-sidebar-inner">
<p class="skills-sidebar-label">Tutorials</p>
<div class="skills-sidebar-group">
<p class="skills-sidebar-group-title">Walk-throughs</p>
<ul class="skills-sidebar-list">
${entries}
</ul>
</div>
</div>
</aside>`;
}
/**
* Render the /tutorials index main content.
*/
@@ -431,16 +427,16 @@ export async function generateSubPages(rootDir) {
const generated = [];
// Skills index: docs-browser layout with sticky sidebar.
// Skills index: docs-browser layout with unified sidebar.
{
const sidebar = renderSkillsSidebar(data.skillsByCategory, null);
const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, null);
const main = renderSkillsOverviewMain(data.skillsByCategory);
const html = renderPage({
title: 'Skills | Impeccable',
description:
'21 commands that teach your AI harness how to design. Browse by category: create, evaluate, refine, simplify, harden, system.',
bodyHtml: wrapInDocsLayout(sidebar, main),
activeNav: 'skills',
activeNav: 'docs',
canonicalPath: '/skills',
bodyClass: 'sub-page skills-layout-page',
});
@@ -451,7 +447,7 @@ export async function generateSubPages(rootDir) {
// Skills detail pages: same docs-browser shell as the overview.
for (const skill of data.skills) {
const sidebar = renderSkillsSidebar(data.skillsByCategory, skill.id);
const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, { kind: 'skill', id: skill.id });
const main = renderSkillDetail(skill, data.knownSkillIds);
const title = `/${skill.id} | Impeccable`;
const description = skill.editorial?.frontmatter?.tagline || skill.description;
@@ -459,7 +455,7 @@ export async function generateSubPages(rootDir) {
title,
description,
bodyHtml: wrapInDocsLayout(sidebar, main),
activeNav: 'skills',
activeNav: 'docs',
canonicalPath: `/skills/${skill.id}`,
bodyClass: 'sub-page skills-layout-page',
});
@@ -486,15 +482,15 @@ export async function generateSubPages(rootDir) {
generated.push(out);
}
// Tutorials index.
// Tutorials index (under the unified Docs umbrella).
if (data.tutorials.length > 0) {
const sidebar = renderTutorialsSidebar(data.tutorials, null);
const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, null);
const main = renderTutorialsIndexMain(data.tutorials);
const html = renderPage({
title: 'Tutorials | Impeccable',
description: `${data.tutorials.length} short, opinionated walk-throughs of the highest-leverage Impeccable workflows.`,
bodyHtml: wrapInDocsLayout(sidebar, main),
activeNav: 'tutorials',
activeNav: 'docs',
canonicalPath: '/tutorials',
bodyClass: 'sub-page skills-layout-page tutorials-page',
});
@@ -505,13 +501,13 @@ export async function generateSubPages(rootDir) {
// Tutorial detail pages.
for (const tutorial of data.tutorials) {
const sidebar = renderTutorialsSidebar(data.tutorials, tutorial.slug);
const sidebar = renderDocsSidebar(data.skillsByCategory, data.tutorials, { kind: 'tutorial', slug: tutorial.slug });
const main = renderTutorialDetail(tutorial, data.knownSkillIds);
const html = renderPage({
title: `${tutorial.title} | Tutorials | Impeccable`,
description: tutorial.description || tutorial.tagline || '',
bodyHtml: wrapInDocsLayout(sidebar, main),
activeNav: 'tutorials',
activeNav: 'docs',
canonicalPath: `/tutorials/${tutorial.slug}`,
bodyClass: 'sub-page skills-layout-page tutorials-page',
});
+4 -1
View File
@@ -56,9 +56,12 @@ export function createRenderer({ knownSkillIds = new Set(), currentSkillId = nul
};
// Fenced code blocks — minimal glass-terminal styling, no syntax highlighter in v1.
// Wrapped in a container with a copy button; click handling lives in the
// page-level inline script added by render-page.js.
renderer.code = ({ text, lang }) => {
const langClass = lang ? ` code-block--${escapeAttr(lang)}` : '';
return `<pre class="code-block${langClass}"><code>${escapeHtml(text)}</code></pre>\n`;
const copyValue = escapeAttr(text);
return `<div class="code-block-wrap"><pre class="code-block${langClass}"><code>${escapeHtml(text)}</code></pre><button class="code-block-copy" type="button" data-copy="${copyValue}" aria-label="Copy to clipboard"></button></div>\n`;
};
return renderer;
+13
View File
@@ -102,6 +102,19 @@ export function renderPage({
<main id="main">
${bodyHtml}
</main>
<script>
// Copy buttons on rendered code blocks
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-copy]');
if (!btn) return;
const text = btn.getAttribute('data-copy');
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
btn.classList.add('is-copied');
setTimeout(() => btn.classList.remove('is-copied'), 1500);
}).catch(() => {});
});
</script>
</body>
</html>
`;