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

+
+
+
+
${before}
+
+
+
${after || before}
+
+
+
+
+ Before + After +
+
+ ${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 ? `
\n${editorialHtml}\n
` : ''}
@@ -125,6 +158,26 @@ function renderDocsSidebar(skillsByCategory, tutorials, current = null) {

Docs

`; + // Tutorials first: walk-throughs are the on-ramp, they go at the top. + if (tutorials && tutorials.length > 0) { + html += ` +
+

Tutorials

+ +
+
+`; + } + + // 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 += ` -
-

Tutorials

- -
-`; - } - 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); + } + })(); diff --git a/scripts/lib/sub-pages-data.js b/scripts/lib/sub-pages-data.js index 26cc9eae3..c402840f6 100644 --- a/scripts/lib/sub-pages-data.js +++ b/scripts/lib/sub-pages-data.js @@ -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));