diff --git a/public/css/sub-pages.css b/public/css/sub-pages.css
index 5c43a00fc..329d03f9e 100644
--- a/public/css/sub-pages.css
+++ b/public/css/sub-pages.css
@@ -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
============================================ */
diff --git a/scripts/build-sub-pages.js b/scripts/build-sub-pages.js
index 48be736a3..530244f8c 100644
--- a/scripts/build-sub-pages.js
+++ b/scripts/build-sub-pages.js
@@ -30,6 +30,35 @@ function escapeHtml(str) {
.replace(/'/g, ''');
}
+/**
+ * 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 `
+
+ Drag to compare
+
+ ${caption ? `${escapeHtml(caption)}
` : ''}
+`;
+}
+
/**
* 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}
+ ${demoHtml}
+
${editorialHtml ? `` : ''}
@@ -125,6 +158,26 @@ function renderDocsSidebar(skillsByCategory, tutorials, current = null) {
`;
+ // Tutorials first: walk-throughs are the on-ramp, they go at the top.
+ if (tutorials && tutorials.length > 0) {
+ html += `
+
+
+`;
+ }
+
+ // 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 += `
-
-`;
- }
-
html += `
`;
@@ -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'),
diff --git a/scripts/lib/render-page.js b/scripts/lib/render-page.js
index 8191f5c8f..038eed930 100644
--- a/scripts/lib/render-page.js
+++ b/scripts/lib/render-page.js
@@ -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);
+ }
+ })();