mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
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:
@@ -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
@@ -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 `
|
||||
<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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user