diff --git a/bun.lock b/bun.lock
index 09b699889..c73679663 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,7 @@
"name": "vibe-design-plugins",
"dependencies": {
"jsdom": "^29.0.0",
+ "marked": "^16.1.0",
},
"devDependencies": {
"archiver": "^7.0.1",
@@ -381,6 +382,8 @@
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
+ "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
+
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"miniflare": ["miniflare@4.20260317.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-xuwk5Kjv+shi5iUBAdCrRl9IaWSGnTU8WuTQzsUS2GlSDIMCJuu8DiF/d9ExjMXYiQG5ml+k9SVKnMj8cRkq0w=="],
diff --git a/content/site/partials/header.html b/content/site/partials/header.html
new file mode 100644
index 000000000..1d1e1b4bd
--- /dev/null
+++ b/content/site/partials/header.html
@@ -0,0 +1,14 @@
+
+
diff --git a/package.json b/package.json
index 080a0142e..c78c0ad45 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,8 @@
"og-image": "bun run scripts/generate-og-image.js"
},
"dependencies": {
- "jsdom": "^29.0.0"
+ "jsdom": "^29.0.0",
+ "marked": "^16.1.0"
},
"optionalDependencies": {
"puppeteer": "^24.39.1"
diff --git a/public/css/main.css b/public/css/main.css
index 77903781c..ea681605a 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -5,108 +5,15 @@
* Light, high-contrast, typographically bold.
*/
-/* Import other CSS partials */
+/* Shared design tokens + minimal reset */
+@import "./tokens.css";
+
+/* Section partials (landing page only) */
@import "./problem-section.css";
@import "./workflow.css";
@import "./gallery.css";
@import "./skill-demos.css";
-/* ============================================
- MINIMAL CSS RESET
- ============================================ */
-
-*, *::before, *::after {
- box-sizing: border-box;
-}
-
-* {
- margin: 0;
-}
-
-img, picture, video, canvas, svg {
- display: block;
- max-width: 100%;
-}
-
-button, input, textarea, select {
- font: inherit;
-}
-
-/* ============================================
- DESIGN TOKENS
- ============================================ */
-
-:root {
- /* Typography */
- --font-display: 'Cormorant Garamond', Georgia, serif;
- --font-body: 'Instrument Sans', system-ui, sans-serif;
- --font-mono: 'Space Grotesk', monospace;
-
- /* Spacing Scale */
- --spacing-xs: 8px;
- --spacing-sm: 16px;
- --spacing-md: 24px;
- --spacing-lg: 32px;
- --spacing-xl: 48px;
- --spacing-2xl: 80px;
- --spacing-3xl: 120px;
-
- /* Width */
- --width-max: 1400px;
- --width-content: 900px;
-
- /* Animation */
- --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
- --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
- --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
- --duration-fast: 0.15s;
- --duration-base: 0.3s;
- --duration-slow: 0.6s;
- --duration-slower: 0.8s;
- --duration-slowest: 1.2s;
-
- /* Core Colors - Light Mode */
- --color-ink: oklch(10% 0 0);
- --color-text: oklch(10% 0 0);
- --color-paper: oklch(98% 0 0);
- --color-cream: oklch(96% 0.005 350);
- --color-charcoal: oklch(25% 0 0);
- --color-ash: oklch(55% 0 0);
- --color-mist: oklch(92% 0 0);
- --color-bg: oklch(96% 0.005 350);
-
- /* Accent - Vibrant Magenta/Rose (original) */
- --color-accent: oklch(60% 0.25 350);
- --color-accent-hover: oklch(52% 0.25 350);
- --color-accent-dim: oklch(60% 0.25 350 / 0.15);
- --color-accent-soft: oklch(60% 0.25 350 / 0.25);
-
- /* Framework category colors - Light mode */
- --cat-create-bg: #fdf2f8;
- --cat-create-border: #ec4899;
- --cat-create-text: #be185d;
-
- --cat-evaluate-bg: #fdf4ff;
- --cat-evaluate-border: #d946ef;
- --cat-evaluate-text: #a21caf;
-
- --cat-refine-bg: #eff6ff;
- --cat-refine-border: #3b82f6;
- --cat-refine-text: #1d4ed8;
-
- --cat-simplify-bg: #fffbeb;
- --cat-simplify-border: #f59e0b;
- --cat-simplify-text: #b45309;
-
- --cat-harden-bg: #f0fdf4;
- --cat-harden-border: #22c55e;
- --cat-harden-text: #15803d;
-
- --cat-system-bg: #f5f5f4;
- --cat-system-border: #78716c;
- --cat-system-text: #44403c;
-}
-
/* ============================================
SKIP LINK (Accessibility)
diff --git a/public/css/sub-pages.css b/public/css/sub-pages.css
new file mode 100644
index 000000000..02f027229
--- /dev/null
+++ b/public/css/sub-pages.css
@@ -0,0 +1,134 @@
+/*
+ * impeccable.style — shared styles for sub-pages and the site-wide header
+ *
+ * Imported by all generated pages under /skills, /anti-patterns, /tutorials,
+ * and by the hand-authored pages (index, cheatsheet, gallery, privacy) via
+ * a direct tag so they all share the same header + prose typography.
+ */
+
+@import "./tokens.css";
+
+/* ============================================
+ BASE
+ ============================================ */
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: var(--font-body);
+ color: var(--color-ink);
+ background: var(--color-paper);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+/* ============================================
+ SHARED SITE HEADER
+ ============================================ */
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-lg);
+ padding: 18px clamp(var(--spacing-md), 4vw, var(--spacing-2xl));
+ background: color-mix(in oklch, var(--color-paper) 94%, transparent);
+ -webkit-backdrop-filter: saturate(1.4) blur(16px);
+ backdrop-filter: saturate(1.4) blur(16px);
+ border-bottom: 1px solid var(--color-mist);
+}
+
+.site-header-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-family: var(--font-display);
+ font-size: 1.375rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--color-ink);
+ flex-shrink: 0;
+ transition: color var(--duration-fast) var(--ease-out);
+}
+
+.site-header-brand:hover {
+ color: var(--color-accent);
+}
+
+.site-header-brand-name {
+ display: inline-block;
+}
+
+.site-header-nav {
+ display: flex;
+ align-items: center;
+ gap: clamp(var(--spacing-sm), 2vw, var(--spacing-lg));
+ font-family: var(--font-body);
+ font-size: 0.9375rem;
+ font-weight: 500;
+}
+
+.site-header-nav a {
+ position: relative;
+ color: var(--color-charcoal);
+ padding: 4px 0;
+ transition: color var(--duration-fast) var(--ease-out);
+}
+
+.site-header-nav a:hover {
+ color: var(--color-accent);
+}
+
+.site-header-nav a[aria-current="page"] {
+ color: var(--color-ink);
+ font-weight: 600;
+}
+
+.site-header-nav a[aria-current="page"]::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -6px;
+ height: 2px;
+ background: var(--color-accent);
+ border-radius: 2px;
+}
+
+@media (max-width: 720px) {
+ .site-header {
+ padding: 14px var(--spacing-md);
+ gap: var(--spacing-sm);
+ }
+
+ .site-header-brand {
+ font-size: 1.125rem;
+ }
+
+ .site-header-nav {
+ font-size: 0.8125rem;
+ gap: var(--spacing-sm);
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+
+ .site-header-nav::-webkit-scrollbar {
+ display: none;
+ }
+
+ .site-header-nav a[data-nav="github"] {
+ display: none;
+ }
+}
diff --git a/public/css/tokens.css b/public/css/tokens.css
new file mode 100644
index 000000000..e5517adac
--- /dev/null
+++ b/public/css/tokens.css
@@ -0,0 +1,102 @@
+/*
+ * impeccable.style — design tokens + minimal reset
+ *
+ * Shared by every page on the site (landing + generated sub-pages).
+ * Keep this file small and dependency-free.
+ */
+
+/* ============================================
+ MINIMAL CSS RESET
+ ============================================ */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+}
+
+img, picture, video, canvas, svg {
+ display: block;
+ max-width: 100%;
+}
+
+button, input, textarea, select {
+ font: inherit;
+}
+
+/* ============================================
+ DESIGN TOKENS
+ ============================================ */
+
+:root {
+ /* Typography */
+ --font-display: 'Cormorant Garamond', Georgia, serif;
+ --font-body: 'Instrument Sans', system-ui, sans-serif;
+ --font-mono: 'Space Grotesk', monospace;
+
+ /* Spacing Scale */
+ --spacing-xs: 8px;
+ --spacing-sm: 16px;
+ --spacing-md: 24px;
+ --spacing-lg: 32px;
+ --spacing-xl: 48px;
+ --spacing-2xl: 80px;
+ --spacing-3xl: 120px;
+
+ /* Width */
+ --width-max: 1400px;
+ --width-content: 900px;
+
+ /* Animation */
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
+ --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
+ --duration-fast: 0.15s;
+ --duration-base: 0.3s;
+ --duration-slow: 0.6s;
+ --duration-slower: 0.8s;
+ --duration-slowest: 1.2s;
+
+ /* Core Colors - Light Mode */
+ --color-ink: oklch(10% 0 0);
+ --color-text: oklch(10% 0 0);
+ --color-paper: oklch(98% 0 0);
+ --color-cream: oklch(96% 0.005 350);
+ --color-charcoal: oklch(25% 0 0);
+ --color-ash: oklch(55% 0 0);
+ --color-mist: oklch(92% 0 0);
+ --color-bg: oklch(96% 0.005 350);
+
+ /* Accent - Vibrant Magenta/Rose (original) */
+ --color-accent: oklch(60% 0.25 350);
+ --color-accent-hover: oklch(52% 0.25 350);
+ --color-accent-dim: oklch(60% 0.25 350 / 0.15);
+ --color-accent-soft: oklch(60% 0.25 350 / 0.25);
+
+ /* Framework category colors - Light mode */
+ --cat-create-bg: #fdf2f8;
+ --cat-create-border: #ec4899;
+ --cat-create-text: #be185d;
+
+ --cat-evaluate-bg: #fdf4ff;
+ --cat-evaluate-border: #d946ef;
+ --cat-evaluate-text: #a21caf;
+
+ --cat-refine-bg: #eff6ff;
+ --cat-refine-border: #3b82f6;
+ --cat-refine-text: #1d4ed8;
+
+ --cat-simplify-bg: #fffbeb;
+ --cat-simplify-border: #f59e0b;
+ --cat-simplify-text: #b45309;
+
+ --cat-harden-bg: #f0fdf4;
+ --cat-harden-border: #22c55e;
+ --cat-harden-text: #15803d;
+
+ --cat-system-bg: #f5f5f4;
+ --cat-system-border: #78716c;
+ --cat-system-text: #44403c;
+}
diff --git a/scripts/lib/render-markdown.js b/scripts/lib/render-markdown.js
new file mode 100644
index 000000000..647bd3f4a
--- /dev/null
+++ b/scripts/lib/render-markdown.js
@@ -0,0 +1,143 @@
+/**
+ * Markdown → HTML rendering for sub-pages.
+ *
+ * Wraps `marked` with a custom link renderer that resolves cross-references
+ * between skill bodies and their references, and emits stable heading slugs
+ * so anti-pattern → skill section anchors work.
+ *
+ * Skeleton in commit 1. Link resolution and heading slugger are wired up in
+ * commit 3 (skills generator) when the data model lands.
+ */
+
+import { marked } from 'marked';
+
+/**
+ * Slugify a heading text into a stable anchor id.
+ * Matches the convention: lowercase, strip non-alphanum, spaces → dashes.
+ *
+ * @param {string} text
+ * @returns {string}
+ */
+export function slugify(text) {
+ return String(text)
+ .toLowerCase()
+ .trim()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/[\s_]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+/**
+ * Build a marked renderer configured for impeccable's skill/tutorial bodies.
+ *
+ * @param {object} opts
+ * @param {Set} [opts.knownSkillIds] - slugs of skills the site knows about; unknown /name mentions render as plain text
+ * @param {string} [opts.currentSkillId] - when rendering a skill body, resolve `reference/foo.md` to `#reference-foo` on the current page
+ * @returns {import('marked').Renderer}
+ */
+export function createRenderer({ knownSkillIds = new Set(), currentSkillId = null } = {}) {
+ const renderer = new marked.Renderer();
+
+ // Heading slugger — stable ids so we can anchor-link from elsewhere.
+ renderer.heading = ({ tokens, depth }) => {
+ const text = renderer.parser.parseInline(tokens);
+ const raw = tokens.map((t) => t.raw || '').join('');
+ const id = slugify(raw);
+ return `${text}\n`;
+ };
+
+ // Link resolver.
+ renderer.link = ({ href, title, tokens }) => {
+ const text = renderer.parser.parseInline(tokens);
+ const resolved = resolveHref(href, { knownSkillIds, currentSkillId });
+ const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
+ const relAttr = resolved.external ? ' target="_blank" rel="noopener"' : '';
+ return `${text}`;
+ };
+
+ // Fenced code blocks — minimal glass-terminal styling, no syntax highlighter in v1.
+ renderer.code = ({ text, lang }) => {
+ const langClass = lang ? ` code-block--${escapeAttr(lang)}` : '';
+ return `${escapeHtml(text)}
\n`;
+ };
+
+ return renderer;
+}
+
+/**
+ * Resolve a markdown link href against the site's URL scheme.
+ *
+ * - `http(s)://…` → unchanged, external
+ * - `reference/foo.md` → `#reference-foo` on current skill page
+ * - `/skill-id` (known) → `/skills/skill-id`
+ * - `#anchor` → unchanged (in-page anchor)
+ * - anything else → unchanged (will be caught by build warnings later)
+ *
+ * @param {string} href
+ * @param {{ knownSkillIds: Set, currentSkillId: string|null }} ctx
+ * @returns {{ href: string, external: boolean }}
+ */
+function resolveHref(href, { knownSkillIds, currentSkillId }) {
+ if (!href) return { href: '', external: false };
+
+ // External links
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
+ return { href, external: true };
+ }
+
+ // In-page anchor
+ if (href.startsWith('#')) {
+ return { href, external: false };
+ }
+
+ // reference/foo.md → #reference-foo on the current skill page
+ const refMatch = href.match(/^reference\/([a-z0-9-]+)\.md$/i);
+ if (refMatch && currentSkillId) {
+ return { href: `#reference-${refMatch[1].toLowerCase()}`, external: false };
+ }
+
+ // /skill-id mentioned in prose (e.g. "run /polish")
+ const slashMatch = href.match(/^\/([a-z0-9-]+)$/i);
+ if (slashMatch && knownSkillIds.has(slashMatch[1])) {
+ return { href: `/skills/${slashMatch[1]}`, external: false };
+ }
+
+ // [text](other-skill) → /skills/other-skill
+ if (/^[a-z0-9-]+$/i.test(href) && knownSkillIds.has(href)) {
+ return { href: `/skills/${href}`, external: false };
+ }
+
+ // Unknown — pass through. Generator can warn separately.
+ return { href, external: false };
+}
+
+/**
+ * Render a markdown string to HTML.
+ *
+ * @param {string} markdown
+ * @param {object} [opts]
+ * @param {Set} [opts.knownSkillIds]
+ * @param {string} [opts.currentSkillId]
+ * @returns {string} HTML
+ */
+export function renderMarkdown(markdown, opts = {}) {
+ const renderer = createRenderer(opts);
+ return marked.parse(markdown, {
+ renderer,
+ gfm: true,
+ breaks: false,
+ });
+}
+
+function escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function escapeAttr(str) {
+ return String(str).replace(/"/g, '"');
+}
diff --git a/scripts/lib/render-page.js b/scripts/lib/render-page.js
new file mode 100644
index 000000000..d46f35814
--- /dev/null
+++ b/scripts/lib/render-page.js
@@ -0,0 +1,114 @@
+/**
+ * Page template wrapper for generated sub-pages.
+ *
+ * Reads the shared site header partial once and wraps content bodies with
+ * a minimal HTML scaffold that imports tokens.css + sub-pages.css.
+ *
+ * Used by scripts/build-sub-pages.js (wired up in commit 3).
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.resolve(__dirname, '..', '..');
+const HEADER_PARTIAL = path.join(ROOT_DIR, 'content', 'site', 'partials', 'header.html');
+
+let cachedHeader = null;
+
+/**
+ * Read the shared site header partial.
+ * Cached after first read.
+ */
+export function readHeaderPartial() {
+ if (cachedHeader === null) {
+ cachedHeader = fs.readFileSync(HEADER_PARTIAL, 'utf8').trim();
+ }
+ return cachedHeader;
+}
+
+/**
+ * Mark a nav item as current by adding aria-current="page" and removing
+ * the default nav href state. Matches on `data-nav="{activeNav}"`.
+ *
+ * @param {string} headerHtml
+ * @param {string} activeNav - one of: home, skills, anti-patterns, tutorials, gallery, github
+ * @returns {string}
+ */
+export function applyActiveNav(headerHtml, activeNav) {
+ if (!activeNav) return headerHtml;
+ return headerHtml.replace(
+ new RegExp(`data-nav="${activeNav}"`, 'g'),
+ `data-nav="${activeNav}" aria-current="page"`,
+ );
+}
+
+/**
+ * Wrap body HTML in a full page shell.
+ *
+ * @param {object} opts
+ * @param {string} opts.title - text
+ * @param {string} opts.description - meta description
+ * @param {string} opts.bodyHtml - main content HTML (will be placed inside )
+ * @param {string} [opts.activeNav] - which nav item to mark current
+ * @param {string} [opts.canonicalPath] - relative URL path for
+ * @param {string} [opts.extraHead] - raw HTML to inject into
+ * @param {string} [opts.bodyClass] - optional class on
+ * @returns {string} full HTML document
+ */
+export function renderPage({
+ title,
+ description,
+ bodyHtml,
+ activeNav,
+ canonicalPath,
+ extraHead = '',
+ bodyClass = 'sub-page',
+}) {
+ const header = applyActiveNav(readHeaderPartial(), activeNav);
+ const safeTitle = escapeHtml(title);
+ const safeDesc = escapeAttr(description || '');
+ const canonical = canonicalPath
+ ? ``
+ : '';
+
+ return `
+
+
+
+
+ ${safeTitle}
+
+
+ ${canonical}
+
+
+
+
+
+ ${extraHead}
+
+
+ Skip to content
+ ${header}
+
+${bodyHtml}
+
+
+
+`;
+}
+
+function escapeHtml(str) {
+ return String(str || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function escapeAttr(str) {
+ return String(str || '').replace(/"/g, '"');
+}