Before/after split demos on skill pages + sidebar reorder

Two changes bundled:

1. Before/after split demos on every skill detail page.
   - loadCommandDemos() in sub-pages-data.js: dynamically imports each
     module in public/js/demos/commands (the same files the homepage
     uses), returning a { skillId: { id, caption, before, after } } map.
     Falls back to a warn-and-continue if a demo file can't be loaded
     so one bad demo doesn't break the whole generator.
   - buildSubPageData becomes async; caller in build-sub-pages.js
     awaits it.
   - Each skill object gets a .demo field (may be null for /shape).
   - renderSkillDemo() produces the .split-comparison markup matching
     the homepage: .split-container with .split-before + .split-after
     + .split-divider, plus Before/After labels and the caption. The
     block sits between the detail header and the editorial wrapper
     so readers see the visual before reading any prose.
   - sub-pages.css ports the core .split-* layout from main.css (the
     .slop-* and .impeccable-* helpers are homepage-specific and not
     copied). Height is 360px to match the docs column.
   - render-page.js grows a lightweight inline split-compare init
     script (60 lines of vanilla JS) that handles drag and the skewed
     clip-path without depending on the homepage's full lerp/ResizeObserver
     module. Runs only on pages that actually have .split-container.

2. Sidebar reorder: Tutorials first, then skills.
   Walk-throughs are the on-ramp; they belong at the top of the sidebar
   where a new visitor will find them. Add <hr class="skills-sidebar-divider">
   between the Tutorials group and the first skill category so the two
   sections read as distinct.

Verified: /skills/polish, /skills/bolder, /skills/critique all render
the demo block. /skills/shape correctly has none. Sidebar on any /skills
or /tutorials page shows Tutorials first, then a thin mist divider,
then Create / Evaluate / Refine / Simplify / Harden / System skill
categories.
This commit is contained in:
Paul Bakaus
2026-04-08 10:54:18 -07:00
parent eb130c4af9
commit 7d7f77d2ba
4 changed files with 265 additions and 19 deletions
+117
View File
@@ -377,6 +377,14 @@ main#main {
margin-bottom: 0;
}
.skills-sidebar-divider {
border: none;
height: 1px;
background: var(--color-mist);
margin: 0 0 1.5rem 0;
width: 100%;
}
.skills-sidebar-group-title {
font-family: var(--font-mono);
font-size: 0.625rem;
@@ -980,6 +988,115 @@ main#main {
}
}
/* ============================================
SKILL DETAIL — BEFORE/AFTER DEMO
============================================ */
/* Ported from main.css for use on sub-pages. The split-compare effect
is initialized by js/effects/split-compare.js loaded on demand. */
.split-comparison {
position: relative;
width: 100%;
max-width: 560px;
margin: 0 auto clamp(2rem, 4vw, 3rem);
}
.split-container {
position: relative;
width: 100%;
max-width: 500px;
height: 360px;
margin: 0 auto;
border-radius: 12px;
overflow: hidden;
background: var(--color-cream);
border: 1px solid var(--color-mist);
cursor: ew-resize;
user-select: none;
}
.split-before,
.split-after {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.split-before {
z-index: 1;
}
.split-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
}
.split-after {
clip-path: polygon(58% 0%, 100% 0%, 100% 100%, 42% 100%);
z-index: 2;
background: var(--color-paper);
}
.split-divider {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 3px;
background: var(--color-accent);
transform: translateX(-50%) skewX(-10deg);
pointer-events: none;
z-index: 3;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
}
.split-labels {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-sm);
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-ash);
}
.split-label-item[data-point="before"] {
color: var(--color-ash);
}
.split-label-item[data-point="after"] {
color: var(--color-accent);
}
.skill-demo-caption {
text-align: center;
font-size: 0.875rem;
color: var(--color-charcoal);
margin-top: var(--spacing-md);
font-style: italic;
}
.skill-demo-eyebrow {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--color-ash);
text-align: center;
margin-bottom: var(--spacing-sm);
}
/* ============================================
SKILL DETAIL
============================================ */
+54 -18
View File
@@ -30,6 +30,35 @@ function escapeHtml(str) {
.replace(/'/g, '&#39;');
}
/**
* Render the before/after split-compare demo block for a skill.
* Returns '' when the skill has no demo data (e.g. /shape).
*/
function renderSkillDemo(skill) {
if (!skill.demo) return '';
const { before, after, caption } = skill.demo;
return `
<section class="skill-demo" aria-label="Before and after demo">
<p class="skill-demo-eyebrow">Drag to compare</p>
<div class="split-comparison" data-demo="skill-${skill.id}">
<div class="split-container">
<div class="split-before">
<div class="split-content">${before}</div>
</div>
<div class="split-after">
<div class="split-content">${after || before}</div>
</div>
<div class="split-divider"></div>
</div>
<div class="split-labels">
<span class="split-label-item" data-point="before">Before</span>
<span class="split-label-item" data-point="after">After</span>
</div>
</div>
${caption ? `<p class="skill-demo-caption">${escapeHtml(caption)}</p>` : ''}
</section>`;
}
/**
* Render one skill detail page HTML body (without the site shell).
*/
@@ -43,6 +72,8 @@ function renderSkillDetail(skill, knownSkillIds) {
? renderMarkdown(skill.editorial.body, { knownSkillIds, currentSkillId: skill.id })
: '';
const demoHtml = renderSkillDemo(skill);
const tagline = skill.editorial?.frontmatter?.tagline || skill.description;
const categoryLabel = CATEGORY_LABELS[skill.category] || skill.category;
@@ -92,6 +123,8 @@ ${refBody}
${metaStrip}
</header>
${demoHtml}
${editorialHtml ? `<section class="skill-detail-editorial prose">\n${editorialHtml}\n</section>` : ''}
<section class="skill-source-card">
@@ -125,6 +158,26 @@ function renderDocsSidebar(skillsByCategory, tutorials, current = null) {
<p class="skills-sidebar-label">Docs</p>
`;
// Tutorials first: walk-throughs are the on-ramp, they go at the top.
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>
</div>
<hr class="skills-sidebar-divider">
`;
}
// Then the skills, grouped by category.
for (const category of CATEGORY_ORDER) {
const list = skillsByCategory[category] || [];
if (list.length === 0) continue;
@@ -144,23 +197,6 @@ ${list
`;
}
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>
</div>
`;
}
html += `
</div>
</aside>`;
@@ -412,7 +448,7 @@ ${sectionsHtml}
* @returns {Promise<{ files: string[] }>} list of generated file paths (absolute)
*/
export async function generateSubPages(rootDir) {
const data = buildSubPageData(rootDir);
const data = await buildSubPageData(rootDir);
const outDirs = {
skills: path.join(rootDir, 'public/skills'),
antiPatterns: path.join(rootDir, 'public/anti-patterns'),
+56
View File
@@ -114,6 +114,62 @@ ${bodyHtml}
setTimeout(() => btn.classList.remove('is-copied'), 1500);
}).catch(() => {});
});
// Lightweight split-compare interaction for before/after demos.
// Drag or hover horizontally over .split-container to sweep the
// divider. No lerp, no ResizeObserver — good enough for docs.
(function initSplitCompare() {
const containers = document.querySelectorAll('.split-container');
if (containers.length === 0) return;
for (const container of containers) {
const splitAfter = container.querySelector('.split-after');
const splitDivider = container.querySelector('.split-divider');
if (!splitAfter || !splitDivider) continue;
const skewAngle = 10 * Math.PI / 180;
const tanAngle = Math.tan(skewAngle);
let skewOffset = 8;
const recalcSkew = () => {
const r = container.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
skewOffset = 50 * r.height * tanAngle / r.width;
}
};
recalcSkew();
window.addEventListener('resize', recalcSkew, { passive: true });
const update = (pct) => {
const x = Math.max(-skewOffset, Math.min(100 + skewOffset, pct));
splitAfter.style.clipPath = \`polygon(\${x + skewOffset}% 0%, 100% 0%, 100% 100%, \${x - skewOffset}% 100%)\`;
splitDivider.style.left = \`\${x}%\`;
};
update(50);
let tracking = false;
const onMove = (clientX) => {
const rect = container.getBoundingClientRect();
const pct = ((clientX - rect.left) / rect.width) * 100;
update(pct);
};
container.addEventListener('pointerdown', (e) => {
tracking = true;
container.setPointerCapture(e.pointerId);
onMove(e.clientX);
});
container.addEventListener('pointermove', (e) => {
if (!tracking) return;
onMove(e.clientX);
});
const stop = (e) => {
if (!tracking) return;
tracking = false;
try { container.releasePointerCapture(e.pointerId); } catch {}
};
container.addEventListener('pointerup', stop);
container.addEventListener('pointercancel', stop);
container.addEventListener('pointerleave', stop);
}
})();
</script>
</body>
</html>
+38 -1
View File
@@ -12,6 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { readSourceFiles, parseFrontmatter } from './utils.js';
/**
@@ -105,6 +106,39 @@ export function readEditorialWrapper(contentDir, kind, slug) {
return parseFrontmatter(content);
}
/**
* Load the per-command before/after demo data from public/js/demos/commands.
* Returns a { [skillId]: { id, caption, before, after } } map.
* Skills without a demo file are simply missing from the map; the caller
* should treat a missing entry as "no demo".
*/
export async function loadCommandDemos(rootDir) {
const demosDir = path.join(rootDir, 'public/js/demos/commands');
if (!fs.existsSync(demosDir)) return {};
const demos = {};
const files = fs
.readdirSync(demosDir)
.filter((f) => f.endsWith('.js') && f !== 'index.js');
for (const file of files) {
const full = path.join(demosDir, file);
try {
const mod = await import(pathToFileURL(full).href);
const demo = mod.default;
if (demo && demo.id) {
demos[demo.id] = demo;
}
} catch (err) {
// Demo files occasionally import other demo modules or use features
// that don't survive dynamic import. Log and move on rather than
// failing the whole generator.
console.warn(`[sub-pages] Could not load demo ${file}: ${err.message}`);
}
}
return demos;
}
/**
* Build the full sub-page data model.
*
@@ -117,9 +151,10 @@ export function readEditorialWrapper(contentDir, kind, slug) {
* tutorials: Array,
* }}
*/
export function buildSubPageData(rootDir) {
export async function buildSubPageData(rootDir) {
const { skills: rawSkills } = readSourceFiles(rootDir);
const contentDir = path.join(rootDir, 'content/site');
const commandDemos = await loadCommandDemos(rootDir);
// Filter to user-invocable, non-deprecated skills.
const skills = rawSkills
@@ -127,6 +162,7 @@ export function buildSubPageData(rootDir) {
.map((s) => {
const category = SKILL_CATEGORIES[s.name];
const editorial = readEditorialWrapper(contentDir, 'skills', s.name);
const demo = commandDemos[s.name] || null;
return {
id: s.name,
name: s.name,
@@ -136,6 +172,7 @@ export function buildSubPageData(rootDir) {
body: s.body,
references: s.references,
editorial, // may be null
demo, // may be null (e.g. /shape has no demo)
};
})
.sort((a, b) => a.name.localeCompare(b.name));