From 4f5703855c723a1d0ab0285a497ad0e0ecc5b69a Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Sat, 26 Sep 2026 13:14:55 +0200 Subject: [PATCH] feat(website): add landing page with interactive learning roadmap (#185) * feat(website): add landing page with interactive learning roadmap The site home was the rendered README, which neither sells the guide nor helps learners track where they are. The new landing page explains the value vs the official docs and turns the curriculum into a checklist roadmap (10 modules, 60 lessons) with progress saved in localStorage. - README now renders to guide.html; landing is index.html (English only, since translated anchors differ) - roadmap.json is validated at build time: every lesson heading must resolve to an anchor, so doc drift fails the build - light/dark theme toggle shared with the docs pages - vendor Fraunces + Manrope; font CSS cache keyed on the request URL - pin build_website.py PEP 723 deps to requirements.txt: markdown 3.11 mis-parses `` in a code span and silently truncated the rendered hooks page * fix(website): address landing page review findings --- scripts/build_website.py | 175 +- scripts/tests/test_build_website.py | 454 ++++- scripts/vendor_assets.py | 15 +- scripts/website_templates/landing.css | 1898 +++++++++++++++++++++ scripts/website_templates/landing.html.j2 | 473 +++++ scripts/website_templates/landing.js | 639 +++++++ scripts/website_templates/roadmap.json | 188 ++ 7 files changed, 3836 insertions(+), 6 deletions(-) create mode 100644 scripts/website_templates/landing.css create mode 100644 scripts/website_templates/landing.html.j2 create mode 100644 scripts/website_templates/landing.js create mode 100644 scripts/website_templates/roadmap.json diff --git a/scripts/build_website.py b/scripts/build_website.py index 0b716fe..f9c6499 100644 --- a/scripts/build_website.py +++ b/scripts/build_website.py @@ -1,7 +1,11 @@ #!/usr/bin/env -S uv run --script # /// script -# dependencies = ["markdown", "beautifulsoup4", "jinja2"] +# dependencies = ["markdown==3.7", "beautifulsoup4==4.12.3", "jinja2==3.1.4"] # /// + +# Versions pinned to match scripts/requirements.txt — markdown 3.11+ mis-parses +# raw `` inside code spans (e.g. `read -r INPUT BuildState: seen.add(item) content = read_source(item_path) page_title = title_from_content(content, display_name) - url = _disambiguate_url(source_to_site_url(item), used_urls, item) + url = source_to_site_url(item) + if config.landing and item == "README.md": + # The landing page takes index.html; the README becomes guide.html. + url = "guide.html" + url = _disambiguate_url(url, used_urls, item) used_urls.add(url.lower()) state.pages.append( PageInfo( @@ -767,6 +779,9 @@ def render_pages( soup = render_markdown_to_soup(md_content) toc = extract_toc_from_soup(soup) rewrite_links_in_soup(soup, page, state, config, logger) + state.page_anchors[page.output_url] = { + str(el["id"]) for el in soup.find_all(id=True) + } html_content = str(soup) prev_page = state.pages[idx - 1] if idx > 0 else None @@ -816,6 +831,159 @@ def render_pages( logger.info(f"Rendered {total} HTML page(s) into {config.output_path}") +# ============================================================================= +# Landing page +# ============================================================================= + + +def _resolve_landing_roadmap( + data: dict[str, object], state: BuildState +) -> tuple[list[dict[str, object]], int, int, list[str]]: + """Resolve roadmap module sources and lesson headings to site URLs. + + Returns (levels, module_count, lesson_count, problems). Every module + `source` must map to a rendered page and every lesson `heading` must + match an element id on that page — problems are collected, not raised, + so a single error lists all drift at once. + """ + problems: list[str] = [] + seen_module_ids: set[str] = set() + seen_lesson_ids: set[str] = set() + resolved_levels: list[dict[str, object]] = [] + module_count = 0 + lesson_count = 0 + + levels = data.get("levels", []) + if not isinstance(levels, list): + raise RuntimeError("roadmap.json: 'levels' must be a list") + + for level in levels: + resolved_modules: list[dict[str, object]] = [] + for module in level.get("modules", []): + mod_id = str(module.get("id", "")) + source = str(module.get("source", "")) + module_count += 1 + if mod_id in seen_module_ids: + problems.append(f"duplicate module id: '{mod_id}'") + seen_module_ids.add(mod_id) + + url = state.source_to_url.get(source) + if url is None: + problems.append( + f"module '{mod_id}': source '{source}' was not rendered" + ) + anchors = state.page_anchors.get(url, set()) if url else set() + + resolved_lessons: list[dict[str, object]] = [] + for lesson in module.get("lessons", []): + lesson_id = str(lesson.get("id", "")) + full_id = f"{mod_id}/{lesson_id}" + lesson_count += 1 + if full_id in seen_lesson_ids: + problems.append(f"duplicate lesson id: '{full_id}'") + seen_lesson_ids.add(full_id) + + heading = str(lesson.get("heading", "")) + anchor = heading_to_anchor(heading) + if url is not None and anchor not in anchors: + problems.append( + f"lesson '{full_id}': heading '{heading}' resolves to " + f"#{anchor}, which is missing from {source}" + ) + resolved_lessons.append( + { + "id": lesson_id, + "full_id": full_id, + "title": lesson.get("title", heading), + "href": ( + relative_link("index.html", url, f"#{anchor}") + if url + else "#" + ), + } + ) + resolved_modules.append( + { + "id": mod_id, + "number": module.get("number", ""), + "title": module.get("title", mod_id), + "time": module.get("time", ""), + "tagline": module.get("tagline", ""), + "url": relative_link("index.html", url) if url else "#", + "lessons": resolved_lessons, + "lesson_count": len(resolved_lessons), + } + ) + resolved_levels.append( + { + "id": level.get("id", ""), + "name": level.get("name", ""), + "title": level.get("title", ""), + "summary": level.get("summary", ""), + "modules": resolved_modules, + } + ) + return resolved_levels, module_count, lesson_count, problems + + +def render_landing( + config: WebsiteConfig, + state: BuildState, + env: Environment, + logger: logging.Logger, +) -> None: + """Render the marketing landing page as `index.html`. + + The landing is generated only for the English build — translated sites + keep their README at index.html because their heading anchors differ. + """ + template_dir = Path(__file__).parent / "website_templates" + roadmap_path = config.roadmap_path or (template_dir / "roadmap.json") + data = json.loads(roadmap_path.read_text(encoding="utf-8")) + + levels, module_count, lesson_count, problems = _resolve_landing_roadmap(data, state) + if problems: + raise RuntimeError( + "roadmap.json does not match the rendered pages:\n - " + + "\n - ".join(problems) + ) + + version = None + readme_text = read_source(config.root_path / "README.md") + if readme_text: + match = re.search(r"badge/version-([\d.]+)", readme_text) + if match: + version = match.group(1) + + template = env.get_template("landing.html.j2") + rendered = template.render( + site_title=config.site_title, + site_subtitle=config.site_subtitle, + levels=levels, + module_count=module_count, + lesson_count=lesson_count, + version=version, + guide_url=state.source_to_url.get("README.md", "guide.html"), + repo_url=config.repo_url, + branch=config.branch, + ) + + out_file = config.output_path / "index.html" + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_text(rendered, encoding="utf-8") + + assets_dir = config.output_path / "assets" + assets_dir.mkdir(parents=True, exist_ok=True) + for name in ("landing.css", "landing.js"): + src = template_dir / name + if src.exists(): + shutil.copy2(src, assets_dir / name) + else: + logger.warning(f"Landing asset missing, skipped: {src}") + + logger.info(f"Rendered landing page → {out_file}") + + # ============================================================================= # Build orchestration # ============================================================================= @@ -853,6 +1021,8 @@ def build_website( ) render_pages(config, state, env, logger) + if config.landing: + render_landing(config, state, env, logger) copy_assets(config, state, logger) # Self-hosted vendor assets — drop all CDN dependencies. @@ -958,6 +1128,7 @@ def main() -> int: repo_url=args.repo_url, branch=args.branch, language=args.lang, + landing=(args.lang == "en"), ) try: diff --git a/scripts/tests/test_build_website.py b/scripts/tests/test_build_website.py index 102f3d5..3e01c1f 100644 --- a/scripts/tests/test_build_website.py +++ b/scripts/tests/test_build_website.py @@ -2,11 +2,16 @@ from __future__ import annotations +import hashlib +import json import logging +import shutil +import subprocess import sys from pathlib import Path import pytest +from bs4 import BeautifulSoup sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -56,7 +61,7 @@ def site_root(tmp_path: Path) -> Path: sc = tmp_path / "01-slash-commands" sc.mkdir() (sc / "README.md").write_text( - "# Slash Commands\n\nMermaid time:\n\n```mermaid\nflowchart LR\nA-->B\n```\n\n" + "# Slash Commands\n\n## Advanced Usage\n\nMermaid time:\n\n```mermaid\nflowchart LR\nA-->B\n```\n\n" "See [example](example.md).\n" ) (sc / "example.md").write_text("# Example\n\nGo back to [overview](README.md).\n") @@ -518,3 +523,450 @@ class TestVendorAssets: with pytest.raises(ValueError, match="non-HTTP URL"): _download("file:///etc/passwd", tmp_path / "out.bin") + + def test_font_css_cache_uses_sha256_url_key( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, logger: logging.Logger + ) -> None: + """Font CSS variants get distinct ten-character SHA-256 cache keys.""" + import vendor_assets + + cache_dir = tmp_path / "vendor-cache" + monkeypatch.setattr(vendor_assets, "_vendor_cache_dir", lambda: cache_dir) + urls = [ + vendor_assets.GOOGLE_FONTS_CSS_URL, + vendor_assets.GOOGLE_FONTS_CSS_URL + "&variant=regression", + ] + downloads: list[str] = [] + + def fake_download( + url: str, dest: Path, headers: dict[str, str] | None = None + ) -> None: + del headers + downloads.append(url) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(f"/* {url} */", encoding="utf-8") + + monkeypatch.setattr(vendor_assets, "_download", fake_download) + + keys: list[str] = [] + for index, url in enumerate(urls): + monkeypatch.setattr(vendor_assets, "GOOGLE_FONTS_CSS_URL", url) + vendor_assets.fetch_fonts(tmp_path / f"output-{index}", logger) + key = hashlib.sha256(url.encode("utf-8")).hexdigest()[:10] + keys.append(key) + assert len(key) == 10 + assert (cache_dir / "fonts" / f"fonts-{key}.css").is_file() + + assert keys[0] != keys[1] + assert downloads == urls + + +# ============================================================================= +# Landing page +# ============================================================================= + + +def _write_roadmap(path: Path, modules: list[dict]) -> Path: + data = { + "levels": [ + { + "id": "beginner", + "name": "Level 1 — Beginner", + "title": "Getting started", + "summary": "Start here.", + "modules": modules, + } + ] + } + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +def _roadmap_module(lessons: list[dict], **overrides) -> dict: + module = { + "id": "slash-commands", + "number": "01", + "title": "Slash Commands", + "source": "01-slash-commands/README.md", + "time": "30 min", + "tagline": "Commands.", + "lessons": lessons, + } + module.update(overrides) + return module + + +class TestLanding: + def _config(self, site_root: Path, out_dir: Path, roadmap: Path) -> WebsiteConfig: + return WebsiteConfig( + root_path=site_root, + output_path=out_dir, + repo_url="https://github.com/example/repo", + branch="main", + landing=True, + roadmap_path=roadmap, + ) + + def test_landing_build( + self, site_root: Path, tmp_path: Path, logger: logging.Logger + ) -> None: + roadmap = _write_roadmap( + tmp_path / "roadmap.json", + [ + _roadmap_module( + [ + { + "id": "overview", + "title": "Overview", + "heading": "Slash Commands", + }, + { + "id": "advanced", + "title": "Advanced usage", + "heading": "Advanced Usage", + }, + ] + ) + ], + ) + out_dir = site_root / "site" + build_website( + self._config(site_root, out_dir, roadmap), logger, skip_vendor=True + ) + + index = out_dir / "index.html" + assert index.exists() + index_html = index.read_text(encoding="utf-8") + soup = BeautifulSoup(index_html, "html.parser") + # The landing takes index.html + assert soup.select_one("#roadmap") is not None + assert soup.select_one('[data-lesson="slash-commands/overview"]') is not None + # The header CTA and live copy status remain available in the rendered page. + assert soup.select_one('header .nav-cta[href="#roadmap"]') is not None + copy_status = soup.select_one("#copy-status") + assert copy_status is not None + assert copy_status.get("role") == "status" + assert copy_status.get("aria-live") == "polite" + # Lesson anchors resolve into the module page + assert "01-slash-commands/index.html#advanced-usage" in index_html + # Landing assets are copied and referenced alongside the site CSS + assert (out_dir / "assets" / "landing.css").exists() + assert (out_dir / "assets" / "landing.js").exists() + assert soup.select_one('link[href="assets/landing.css"]') is not None + assert soup.select_one('script[src="assets/landing.js"]') is not None + # The landing must not pull in the docs-page stylesheet or CDNs + assert "tailwind.css" not in index_html + for hostile in ("cdn.tailwindcss.com", "fonts.googleapis.com"): + assert hostile not in index_html + + # The README moved to guide.html + guide = out_dir / "guide.html" + assert guide.exists() + assert "Home Page" in guide.read_text(encoding="utf-8") + + # Links from other pages to README.md now resolve to guide.html + roadmap_page = (out_dir / "LEARNING-ROADMAP.html").read_text(encoding="utf-8") + assert "guide.html#home-page" in roadmap_page + assert "index.html#home-page" not in roadmap_page + + def test_landing_missing_heading( + self, site_root: Path, tmp_path: Path, logger: logging.Logger + ) -> None: + roadmap = _write_roadmap( + tmp_path / "roadmap.json", + [ + _roadmap_module( + [ + { + "id": "ghost", + "title": "Ghost", + "heading": "Does Not Exist", + } + ] + ) + ], + ) + with pytest.raises(RuntimeError, match="Does Not Exist"): + build_website( + self._config(site_root, site_root / "site", roadmap), + logger, + skip_vendor=True, + ) + + def test_landing_missing_source( + self, site_root: Path, tmp_path: Path, logger: logging.Logger + ) -> None: + roadmap = _write_roadmap( + tmp_path / "roadmap.json", + [ + _roadmap_module( + [{"id": "x", "title": "X", "heading": "X"}], + source="99-nope/README.md", + ) + ], + ) + with pytest.raises(RuntimeError, match=r"99-nope/README\.md"): + build_website( + self._config(site_root, site_root / "site", roadmap), + logger, + skip_vendor=True, + ) + + def test_landing_duplicate_ids( + self, site_root: Path, tmp_path: Path, logger: logging.Logger + ) -> None: + lesson = {"id": "overview", "title": "Overview", "heading": "Slash Commands"} + roadmap = _write_roadmap( + tmp_path / "roadmap.json", + [_roadmap_module([lesson, dict(lesson)])], + ) + with pytest.raises(RuntimeError, match="duplicate lesson id"): + build_website( + self._config(site_root, site_root / "site", roadmap), + logger, + skip_vendor=True, + ) + + def test_landing_real_repo(self, tmp_path: Path, logger: logging.Logger) -> None: + """Every heading in the shipped roadmap.json must resolve to an anchor.""" + repo_root = Path(__file__).resolve().parents[2] + out_dir = tmp_path / "site" + config = WebsiteConfig( + root_path=repo_root, + output_path=out_dir, + landing=True, + ) + build_website(config, logger, skip_vendor=True) + index_html = (out_dir / "index.html").read_text(encoding="utf-8") + assert 'id="roadmap"' in index_html + assert 'data-lesson="slash-commands/overview"' in index_html + assert (out_dir / "guide.html").exists() + + def test_landing_off_keeps_readme_index( + self, site_root: Path, logger: logging.Logger + ) -> None: + out_dir = site_root / "site" + config = WebsiteConfig( + root_path=site_root, + output_path=out_dir, + landing=False, + ) + build_website(config, logger, skip_vendor=True) + index_html = (out_dir / "index.html").read_text(encoding="utf-8") + assert "Home Page" in index_html + assert 'id="roadmap"' not in index_html + assert not (out_dir / "guide.html").exists() + + +LANDING_JS_NODE_HARNESS = r""" +const assert = require("assert"); +const fs = require("fs"); +const vm = require("vm"); +const source = fs.readFileSync(process.argv[1], "utf8"); + +function element(id = null, classes = [], attrs = {}) { + const names = new Set(classes); + const listeners = {}; + const item = { + id, attrs, style: {}, hidden: false, textContent: "", + classList: { + add(...values) { values.forEach((value) => names.add(value)); }, + remove(...values) { values.forEach((value) => names.delete(value)); }, + contains(value) { return names.has(value); }, + toggle(value, force) { + const next = force === undefined ? !names.has(value) : force; + if (next) names.add(value); else names.delete(value); + return next; + }, + }, + getAttribute(name) { return name === "id" ? item.id : item.attrs[name] ?? null; }, + setAttribute(name, value) { item.attrs[name] = String(value); }, + addEventListener(name, handler) { (listeners[name] ||= []).push(handler); }, + dispatch(name) { (listeners[name] || []).forEach((handler) => handler({ target: item })); }, + querySelector: () => null, + querySelectorAll: () => [], + closest: () => null, + appendChild: () => {}, + removeChild: () => {}, + select: () => {}, + }; + return item; +} + +function makeEnvironment(spec) { + const ids = new Map(); + const register = (id, classes = []) => { + const item = element(id, classes); + ids.set(id, item); + return item; + }; + const copy = element(null, [], { "data-copy": "echo copied" }); + const label = element(); + label.classList.add("copy-label"); + label.textContent = "Copy"; + copy.querySelector = (selector) => selector === ".copy-label" ? label : null; + const document = { + documentElement: element(null, ["no-js"]), + body: element(), + getElementById: (id) => ids.get(id) || null, + querySelectorAll: (selector) => selector === "[data-copy]" ? [copy] : [], + createElement: () => element(), + addEventListener: () => {}, + }; + register("top", ["nav"]); + register("nav-toggle"); + register("nav-menu"); + register("copy-status"); + + let clipboardCalls = 0; + let execCalls = 0; + let copiedText = null; + let resolveClipboard; + const timers = new Map(); + let nextTimer = 1; + document.execCommand = () => { + execCalls += 1; + if (spec.exec === "throw") throw new Error("copy denied"); + return spec.exec === "true"; + }; + const clipboard = spec.clipboard ? { + writeText(text) { + clipboardCalls += 1; + copiedText = text; + if (spec.clipboard === "deferred") { + return new Promise((resolve) => { resolveClipboard = resolve; }); + } + return spec.clipboard === "reject" + ? Promise.reject(new Error("clipboard unavailable")) + : Promise.resolve(); + }, + } : null; + const navigator = { clipboard }; + const storage = new Map(); + const window = { + innerHeight: 800, + localStorage: { + getItem: (key) => storage.get(key) || null, + setItem: (key, value) => storage.set(key, value), + }, + matchMedia: () => ({ matches: false }), + setTimeout: (fn) => { + const id = nextTimer++; + timers.set(id, fn); + return id; + }, + clearTimeout: (id) => timers.delete(id), + addEventListener: () => {}, + requestAnimationFrame: () => {}, + confirm: () => true, + navigator, + }; + return { + copy, document, top: ids.get("top"), + context: { console, Date, document, navigator, Promise, window }, + counters: () => ({ clipboardCalls, copiedText, execCalls }), + resolveClipboard: () => resolveClipboard(), + flushTimers: () => { + const pending = Array.from(timers.values()); + timers.clear(); + pending.forEach((fn) => fn()); + }, + }; +} + +function runCase(spec) { + const env = makeEnvironment(spec); + vm.runInNewContext(source, env.context, { filename: "landing.js" }); + env.copy.dispatch("click"); + return new Promise((resolve) => setImmediate(() => { + const label = env.copy.querySelector(".copy-label"); + const status = env.document.getElementById("copy-status"); + resolve({ + name: spec.name, label: label.textContent, status: status.textContent, + copied: env.copy.classList.contains("copied"), + navEnhanced: env.top.classList.contains("nav-enhanced"), + ...env.counters(), + }); + })); +} + +async function runRapidClicks() { + const spec = { clipboard: null, exec: "true" }; + const env = makeEnvironment(spec); + vm.runInNewContext(source, env.context, { filename: "landing.js" }); + const label = env.copy.querySelector(".copy-label"); + const status = env.document.getElementById("copy-status"); + env.copy.dispatch("click"); + assert.strictEqual(label.textContent, "Copied"); + spec.exec = "false"; + env.copy.dispatch("click"); + assert.strictEqual(label.textContent, "Copy failed"); + env.flushTimers(); + assert.strictEqual(label.textContent, "Copy"); + assert.strictEqual(status.textContent, "Unable to copy to clipboard"); + assert.strictEqual(env.copy.classList.contains("copied"), false); + + const pending = makeEnvironment({ clipboard: "deferred", exec: "false" }); + vm.runInNewContext(source, pending.context, { filename: "landing.js" }); + pending.copy.dispatch("click"); + pending.context.navigator.clipboard = null; + pending.copy.dispatch("click"); + pending.resolveClipboard(); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(pending.copy.querySelector(".copy-label").textContent, "Copy failed"); + assert.strictEqual(pending.document.getElementById("copy-status").textContent, "Unable to copy to clipboard"); + pending.flushTimers(); + assert.strictEqual(pending.copy.querySelector(".copy-label").textContent, "Copy"); + return { name: "rapid-clicks" }; +} + +const cases = [ + ["clipboard-success", "resolve", "false", "Copied", "Copied to clipboard", true, 0, 1, "echo copied"], + ["legacy-false", null, "false", "Copy failed", "Unable to copy to clipboard", false, 1, 0, null], + ["legacy-throw", null, "throw", "Copy failed", "Unable to copy to clipboard", false, 1, 0, null], + ["rejection-legacy-success", "reject", "true", "Copied", "Copied to clipboard", true, 1, 1, "echo copied"], + ["rejection-legacy-false", "reject", "false", "Copy failed", "Unable to copy to clipboard", false, 1, 1, "echo copied"], +]; +Promise.all([ + ...cases.map(([name, clipboard, exec]) => runCase({ name, clipboard, exec })), + runRapidClicks(), +]) + .then((results) => { + results.slice(0, cases.length).forEach((result, index) => { + const [, , , label, status, copied, execCalls, clipboardCalls, copiedText] = cases[index]; + assert.deepStrictEqual( + [result.label, result.status, result.copied, result.execCalls, result.clipboardCalls, result.copiedText, result.navEnhanced], + [label, status, copied, execCalls, clipboardCalls, copiedText, true], result.name, + ); + }); + process.stdout.write(JSON.stringify(results)); + }) + .catch((error) => { + console.error(error.stack || error); + process.exitCode = 1; + }); +""" + + +class TestLandingJavaScript: + @pytest.mark.skipif(shutil.which("node") is None, reason="node is required") + def test_copy_fallbacks_and_mobile_nav_enhancement(self) -> None: + node = shutil.which("node") + assert node is not None + landing_js = Path(__file__).parent.parent / "website_templates" / "landing.js" + result = subprocess.run( + [node, "-e", LANDING_JS_NODE_HARNESS, str(landing_js)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + results = json.loads(result.stdout) + assert {result["name"] for result in results} == { + "clipboard-success", + "legacy-false", + "legacy-throw", + "rejection-legacy-success", + "rejection-legacy-false", + "rapid-clicks", + } diff --git a/scripts/vendor_assets.py b/scripts/vendor_assets.py index df91796..e36bba5 100644 --- a/scripts/vendor_assets.py +++ b/scripts/vendor_assets.py @@ -11,6 +11,7 @@ use v3-style runtime configuration; Tailwind v4 deprecated that config format. from __future__ import annotations +import hashlib import json import logging import os @@ -29,8 +30,10 @@ MERMAID_URL = ( ) GOOGLE_FONTS_CSS_URL = ( "https://fonts.googleapis.com/css2?" - "family=Inter:wght@400;500;600;700;800" + "family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..600" + "&family=Inter:wght@400;500;600;700;800" "&family=JetBrains+Mono:wght@400;500;600" + "&family=Manrope:wght@400;500;600;700" "&display=swap" ) GOOGLE_FONTS_UA = ( @@ -160,7 +163,10 @@ def fetch_fonts(target_dir: Path, logger: logging.Logger) -> Path: """Download Google Fonts CSS + WOFF2 files, rewrite URLs to relative paths.""" cache = _vendor_cache_dir() / "fonts" cache.mkdir(parents=True, exist_ok=True) - css_cache = cache / "fonts.css" + # Key the cached CSS on the request URL so adding/removing font families + # invalidates the cache instead of silently serving the old stylesheet. + css_key = hashlib.sha256(GOOGLE_FONTS_CSS_URL.encode("utf-8")).hexdigest()[:10] + css_cache = cache / f"fonts-{css_key}.css" files_cache = cache / "files" files_cache.mkdir(parents=True, exist_ok=True) @@ -201,7 +207,10 @@ def write_vendor_manifest(target_dir: Path, fonts_count: int) -> None: manifest = { "tailwind": TAILWIND_VERSION, "mermaid": MERMAID_VERSION, - "fonts": {"family": ["Inter", "JetBrains Mono"], "files": fonts_count}, + "fonts": { + "family": ["Fraunces", "Inter", "JetBrains Mono", "Manrope"], + "files": fonts_count, + }, } target_dir.mkdir(parents=True, exist_ok=True) (target_dir / "manifest.json").write_text( diff --git a/scripts/website_templates/landing.css b/scripts/website_templates/landing.css new file mode 100644 index 0000000..48e3e8c --- /dev/null +++ b/scripts/website_templates/landing.css @@ -0,0 +1,1898 @@ +/* ============================================================================= + claude-howto landing — "Terminal Luxe" (dark) / "Ivory" (light) + All colors flow through CSS custom properties: `:root` holds the light + tokens, `html.dark` overrides them with the Terminal Luxe palette, and the + no-JS media query mirrors `html.dark` (the inline script removes .no-js + when JS runs, so a no-JS visitor on a dark OS still gets dark colors). + Emerald #22C55E stays decorative in both themes — lines, borders, the + subway track, the progress ring, checkmarks — never a background fill. + Green *text* uses --green (#15803D in light for WCAG AA contrast). + ============================================================================= */ + +:root { + /* ---------- Dark palette constants ---------- + Referenced by both the html.dark block and the no-JS media query below, + so the Terminal Luxe values live once here and both blocks stay in sync. */ + --d-bg: #000; + --d-panel: rgba(255, 255, 255, 0.03); + --d-panel-2: rgba(255, 255, 255, 0.05); + --d-line: rgba(255, 255, 255, 0.09); + --d-line-soft: rgba(255, 255, 255, 0.06); + --d-line-hover: rgba(255, 255, 255, 0.2); + --d-line-strong: rgba(255, 255, 255, 0.3); + --d-line-bright: rgba(255, 255, 255, 0.35); + --d-node-ring: rgba(255, 255, 255, 0.28); + --d-text: #fff; + --d-text-dim: rgba(255, 255, 255, 0.7); + --d-text-faint: rgba(255, 255, 255, 0.5); + --d-green: #22c55e; + --d-amber: #f59e0b; + --d-blue: #3b82f6; + --d-red: #ef4444; + --d-grid: rgba(255, 255, 255, 0.028); + --d-grain: 0.035; + --d-nav: rgba(0, 0, 0, 0.72); + --d-nav-menu: rgba(0, 0, 0, 0.94); + --d-selection: #fff; + --d-em-glow: 0 0 32px rgba(34, 197, 94, 0.35); + --d-shadow-btn: + 0 0 28px var(--green-glow), inset 0 0 18px rgba(34, 197, 94, 0.06); + --d-shadow-card: + 0 14px 40px rgba(0, 0, 0, 0.5), 0 0 30px rgba(34, 197, 94, 0.05); + --d-shadow-done: 0 0 26px rgba(34, 197, 94, 0.06); + --d-shadow-term: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 24px 70px rgba(0, 0, 0, 0.65), + 0 0 60px rgba(34, 197, 94, 0.07); + + /* ---------- Semantic tokens — "Ivory" light defaults ---------- + Warm near-white canvas, black text, black-at-low-alpha borders, soft + shadows for depth instead of glows. */ + --bg: #fbfaf7; + --panel: rgba(96, 78, 46, 0.045); + --panel-2: rgba(96, 78, 46, 0.075); + --line: rgba(0, 0, 0, 0.11); + --line-soft: rgba(0, 0, 0, 0.07); + --line-hover: rgba(0, 0, 0, 0.18); + --line-strong: rgba(0, 0, 0, 0.26); + --line-bright: rgba(0, 0, 0, 0.34); + --node-ring: rgba(0, 0, 0, 0.26); + --text: #101210; + --text-dim: rgba(0, 0, 0, 0.68); + --text-faint: rgba(0, 0, 0, 0.56); + --green: #15803d; + --amber: #b45309; + --blue: #1d4ed8; + --red: #dc2626; + --grid: rgba(0, 0, 0, 0.038); + --grain: 0.05; + --nav: rgba(251, 250, 247, 0.8); + --nav-menu: rgba(251, 250, 247, 0.96); + --selection: #101210; + --em-glow: 0 0 28px rgba(34, 197, 94, 0.16); + --shadow-btn: + 0 0 24px rgba(34, 197, 94, 0.16), inset 0 0 18px rgba(34, 197, 94, 0.05); + --shadow-card: + 0 14px 40px rgba(16, 20, 17, 0.1), 0 0 30px rgba(34, 197, 94, 0.05); + --shadow-done: + 0 10px 32px rgba(16, 20, 17, 0.08), 0 0 26px rgba(34, 197, 94, 0.07); + --shadow-term: + 0 0 0 1px rgba(0, 0, 0, 0.16), + 0 24px 60px rgba(16, 20, 17, 0.18), + 0 0 60px rgba(34, 197, 94, 0.09); + + /* ---------- Theme-independent emerald ---------- + #22C55E family for lines, borders, the subway track, the progress ring + stroke and checkmarks — identical in both themes. */ + --green-vivid: #22c55e; + --green-soft: rgba(34, 197, 94, 0.7); + --green-line: rgba(34, 197, 94, 0.35); + --green-glow: rgba(34, 197, 94, 0.22); + --green-tint-03: rgba(34, 197, 94, 0.03); + --green-tint-045: rgba(34, 197, 94, 0.045); + --green-tint-05: rgba(34, 197, 94, 0.05); + --green-tint-06: rgba(34, 197, 94, 0.06); + --green-tint-18: rgba(34, 197, 94, 0.18); + --green-tint-28: rgba(34, 197, 94, 0.28); + --green-tint-45: rgba(34, 197, 94, 0.45); + --green-tint-55: rgba(34, 197, 94, 0.55); + --green-none: rgba(34, 197, 94, 0); + --amber-line: rgba(245, 158, 11, 0.5); + + /* ---------- Dark "chip" surfaces ---------- + The hero terminal and the copy-code chips stay dark in both themes; + their internals read only these tokens so they never flip. */ + --chip-bg: #030604; + --chip-panel: rgba(255, 255, 255, 0.05); + --chip-line: rgba(255, 255, 255, 0.09); + --chip-line-soft: rgba(255, 255, 255, 0.06); + --chip-text: #fff; + --chip-dim: rgba(255, 255, 255, 0.7); + --chip-faint: rgba(255, 255, 255, 0.5); + --chip-green: #22c55e; + --chip-blue: #3b82f6; + --chip-amber: #f59e0b; + + --font-display: "Fraunces", "Iowan Old Style", Georgia, serif; + --font-body: "Manrope", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --wrap: 72rem; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + color-scheme: light; +} + +/* ---------- Terminal Luxe dark tokens ---------- */ + +html.dark { + --bg: var(--d-bg); + --panel: var(--d-panel); + --panel-2: var(--d-panel-2); + --line: var(--d-line); + --line-soft: var(--d-line-soft); + --line-hover: var(--d-line-hover); + --line-strong: var(--d-line-strong); + --line-bright: var(--d-line-bright); + --node-ring: var(--d-node-ring); + --text: var(--d-text); + --text-dim: var(--d-text-dim); + --text-faint: var(--d-text-faint); + --green: var(--d-green); + --amber: var(--d-amber); + --blue: var(--d-blue); + --red: var(--d-red); + --grid: var(--d-grid); + --grain: var(--d-grain); + --nav: var(--d-nav); + --nav-menu: var(--d-nav-menu); + --selection: var(--d-selection); + --em-glow: var(--d-em-glow); + --shadow-btn: var(--d-shadow-btn); + --shadow-card: var(--d-shadow-card); + --shadow-done: var(--d-shadow-done); + --shadow-term: var(--d-shadow-term); + color-scheme: dark; +} + +/* No-JS: no inline script ran, so follow the OS scheme directly. + Keep this block identical to html.dark above. */ +@media (prefers-color-scheme: dark) { + html.no-js { + --bg: var(--d-bg); + --panel: var(--d-panel); + --panel-2: var(--d-panel-2); + --line: var(--d-line); + --line-soft: var(--d-line-soft); + --line-hover: var(--d-line-hover); + --line-strong: var(--d-line-strong); + --line-bright: var(--d-line-bright); + --node-ring: var(--d-node-ring); + --text: var(--d-text); + --text-dim: var(--d-text-dim); + --text-faint: var(--d-text-faint); + --green: var(--d-green); + --amber: var(--d-amber); + --blue: var(--d-blue); + --red: var(--d-red); + --grid: var(--d-grid); + --grain: var(--d-grain); + --nav: var(--d-nav); + --nav-menu: var(--d-nav-menu); + --selection: var(--d-selection); + --em-glow: var(--d-em-glow); + --shadow-btn: var(--d-shadow-btn); + --shadow-card: var(--d-shadow-card); + --shadow-done: var(--d-shadow-done); + --shadow-term: var(--d-shadow-term); + color-scheme: dark; + } +} + +/* ---------- Reset ---------- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + scroll-padding-top: 5.5rem; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + font-size: 1rem; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* Faint blueprint grid over the whole page */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(var(--grid) 1px, transparent 1px), + linear-gradient(90deg, var(--grid) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: radial-gradient(ellipse 120% 90% at 50% 0%, #000 30%, transparent 78%); + -webkit-mask-image: radial-gradient(ellipse 120% 90% at 50% 0%, #000 30%, transparent 78%); +} + +/* Film grain */ +body::after { + content: ""; + position: fixed; + inset: -50%; + z-index: 90; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); + opacity: var(--grain); +} + +main, +.nav, +.footer { + position: relative; + z-index: 1; +} + +img { + max-width: 100%; +} + +a { + color: var(--green); + text-decoration: none; +} + +a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +code { + font-family: var(--font-mono); + font-size: 0.875em; + color: var(--green); + border: 1px solid var(--green-line); + border-radius: 5px; + padding: 0.08em 0.4em; + white-space: nowrap; +} + +:focus-visible { + outline: 2px solid var(--green-vivid); + outline-offset: 3px; + border-radius: 4px; +} + +::selection { + background: var(--green-tint-28); + color: var(--selection); +} + +.skip-link { + position: absolute; + left: 1rem; + top: -3rem; + z-index: 200; + padding: 0.5rem 1rem; + border: 1px solid var(--green-vivid); + border-radius: 8px; + background: var(--bg); + color: var(--green); + font-family: var(--font-mono); + font-size: 0.8rem; + transition: top 0.2s; +} + +.skip-link:focus { + top: 0.75rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---------- Buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.8rem 1.6rem; + border-radius: 10px; + border: 1px solid transparent; + font-family: var(--font-body); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + transition: + border-color 0.25s var(--ease-out), + box-shadow 0.25s var(--ease-out), + color 0.25s var(--ease-out), + translate 0.25s var(--ease-out), + background-color 0.25s var(--ease-out); +} + +#continue-btn[hidden] { + display: none; +} + +.btn:hover { + text-decoration: none; + translate: 0 -1px; +} + +.btn-primary { + color: var(--green); + border-color: var(--green-soft); + background: transparent; +} + +.btn-primary:hover { + color: var(--green); + border-color: var(--green-vivid); + box-shadow: var(--shadow-btn); +} + +.btn-ghost { + color: var(--text); + border-color: var(--line); + background: transparent; +} + +.btn-ghost:hover { + border-color: var(--line-bright); +} + +.btn-sm { + padding: 0.5rem 1.05rem; + font-size: 0.85rem; + border-radius: 8px; +} + +/* ---------- Nav ---------- */ + +.nav { + position: sticky; + top: 0; + z-index: 80; + border-bottom: 1px solid var(--line-soft); + background: var(--nav); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.nav-inner { + max-width: var(--wrap); + margin: 0 auto; + display: flex; + align-items: center; + gap: 1.25rem; + padding: 0.85rem 1.5rem; +} + +.nav-brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + color: var(--text); +} + +.nav-brand:hover { + text-decoration: none; +} + +.nav-logo { + height: 30px; + width: auto; +} + +/* Logo swap: light wordmark on the Ivory theme, dark wordmark on + Terminal Luxe. The no-JS media query mirrors html.dark. */ +.logo-dark { + display: none; +} + +html.dark .logo-light { + display: none; +} + +html.dark .logo-dark { + display: inline; +} + +@media (prefers-color-scheme: dark) { + html.no-js .logo-light { + display: none; + } + + html.no-js .logo-dark { + display: inline; + } +} + +.nav-links { + display: flex; + align-items: center; + gap: 1.6rem; + margin-left: auto; +} + +.nav-links a { + color: var(--text-dim); + font-size: 0.88rem; + font-weight: 500; + transition: color 0.2s; +} + +.nav-links a:hover { + color: var(--text); + text-decoration: none; +} + +.nav-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.nav-links + .nav-actions { + margin-left: 0; +} + +.nav-toggle { + display: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--line); + border-radius: 9px; + background: transparent; + color: var(--text-dim); + cursor: pointer; +} + +.nav-toggle svg { + width: 18px; + height: 18px; +} + +.nav-toggle .menu-close { + display: none; +} + +.nav-toggle[aria-expanded="true"] .menu-open { + display: none; +} + +.nav-toggle[aria-expanded="true"] .menu-close { + display: block; +} + +/* ---------- Theme toggle ---------- */ + +.theme-toggle { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex: none; + border: 1px solid var(--line); + border-radius: 9px; + background: transparent; + color: var(--text-dim); + cursor: pointer; + transition: + color 0.2s, + border-color 0.2s; +} + +.theme-toggle:hover { + color: var(--text); + border-color: var(--line-strong); +} + +.no-js .theme-toggle { + display: none; +} + +.theme-toggle svg { + position: absolute; + left: 50%; + top: 50%; + width: 18px; + height: 18px; + translate: -50% -50%; + transition: + opacity 0.25s var(--ease-out), + rotate 0.3s var(--ease-out); +} + +.theme-toggle .ti-sun { + opacity: 0; + rotate: -80deg; +} + +html.dark .theme-toggle .ti-sun { + opacity: 1; + rotate: 0deg; +} + +html.dark .theme-toggle .ti-moon { + opacity: 0; + rotate: 80deg; +} + +/* Cross-fade colors for ~200ms while the theme flips. The class is added + by JS on toggle only — never on first paint. */ +html.theme-anim body, +html.theme-anim body * { + transition: + background-color 0.2s ease, + color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease !important; +} + +/* ---------- Hero ---------- */ + +.hero { + max-width: var(--wrap); + margin: 0 auto; + padding: 5.5rem 1.5rem 4rem; +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); + gap: 3.5rem; + align-items: center; +} + +.eyebrow { + margin: 0 0 1.4rem; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--green); + letter-spacing: 0.06em; +} + +.eyebrow-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem 0.85rem; + margin-bottom: 1.4rem; +} + +.eyebrow-row .eyebrow { + margin-bottom: 0; +} + +.version-pill { + font-family: var(--font-mono); + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--green); + border: 1px solid var(--green-line); + border-radius: 999px; + padding: 0.24rem 0.7rem; + white-space: nowrap; +} + +.hero-title { + margin: 0 0 1.4rem; + font-family: var(--font-display); + font-size: clamp(2.6rem, 5.6vw, 4.3rem); + font-weight: 480; + line-height: 1.04; + letter-spacing: -0.015em; + font-variation-settings: "opsz" 144; +} + +.hero-title em { + font-style: italic; + color: var(--green); + text-shadow: var(--em-glow); +} + +.hero-sub { + margin: 0 0 2rem; + max-width: 34rem; + font-size: 1.08rem; + line-height: 1.7; + color: var(--text-dim); +} + +.hero-cta { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + margin-bottom: 2.6rem; +} + +.hero-stats { + display: flex; + flex-wrap: wrap; + gap: 0; + margin: 0; + padding: 0; + border-top: 1px solid var(--line-soft); +} + +.stat { + padding: 1rem 1.8rem 0 0; + margin-right: 1.8rem; + border-right: 1px solid var(--line-soft); +} + +.stat:last-child { + border-right: 0; + margin-right: 0; + padding-right: 0; +} + +.stat dt { + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-faint); + margin-bottom: 0.25rem; +} + +.stat dd { + margin: 0; + font-family: var(--font-display); + font-size: 1.45rem; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; +} + +/* ---------- Terminal ---------- */ + +.term { + border: 1px solid var(--green-line); + border-radius: 14px; + background: + linear-gradient(180deg, var(--green-tint-045), var(--green-none) 34%), + var(--chip-bg); + box-shadow: var(--shadow-term); + overflow: hidden; +} + +.term-bar { + display: flex; + align-items: center; + gap: 7px; + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--chip-line-soft); +} + +.term-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid var(--chip-line); + background: var(--chip-panel); +} + +.term-title { + margin-left: auto; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--chip-faint); + letter-spacing: 0.04em; +} + +.term-body { + padding: 1.1rem 1.25rem 1.35rem; + font-family: var(--font-mono); + font-size: 0.82rem; + line-height: 1.85; + min-height: 17.5rem; +} + +.t-line { + display: flex; + gap: 0.55rem; + white-space: pre-wrap; + word-break: break-word; + min-height: 1.5em; +} + +.t-pp { + color: var(--chip-green); + flex: none; +} + +.t-pp-in { + color: var(--chip-blue); +} + +.t-tt { + color: var(--chip-text); +} + +.t-tt.t-typing::after, +.t-line.t-caret .t-tt::after { + content: "▌"; + margin-left: 1px; + color: var(--chip-green); + animation: caret-blink 1.05s steps(1) infinite; +} + +.t-line.t-caret .t-tt:empty::after { + margin-left: 0.15rem; +} + +@keyframes caret-blink { + 0%, 55% { opacity: 1; } + 56%, 100% { opacity: 0; } +} + +.t-line[data-c="dim"] { color: var(--chip-faint); } +.t-line[data-c="ok"] { color: var(--chip-green); } +.t-line[data-c="info"] { color: var(--chip-blue); } +.t-line[data-c="warn"] { color: var(--chip-amber); } + +/* ---------- Sections ---------- */ + +.section { + max-width: var(--wrap); + margin: 0 auto; + padding: 5rem 1.5rem 3rem; +} + +.kicker { + margin: 0 0 0.9rem; + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--green); +} + +.kicker::before { + content: "▸ "; +} + +.section-title { + margin: 0 0 1.1rem; + font-family: var(--font-display); + font-size: clamp(1.9rem, 3.6vw, 2.9rem); + font-weight: 480; + line-height: 1.12; + letter-spacing: -0.01em; +} + +.section-title em { + font-style: italic; + color: var(--green); +} + +.section-sub { + margin: 0 0 2.5rem; + max-width: 40rem; + color: var(--text-dim); + font-size: 1.02rem; +} + +/* ---------- Why cards ---------- */ + +.cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.card { + min-width: 0; + padding: 1.6rem 1.4rem 1.5rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + transition: + border-color 0.3s var(--ease-out), + translate 0.3s var(--ease-out), + box-shadow 0.3s var(--ease-out); +} + +.card:hover { + border-color: var(--green-line); + translate: 0 -3px; + box-shadow: var(--shadow-card); +} + +.card-glyph { + display: inline-flex; + padding: 0.55rem; + border: 1px solid var(--green-line); + border-radius: 10px; + color: var(--green); + margin-bottom: 1.1rem; +} + +.card-glyph svg { + width: 20px; + height: 20px; +} + +.card h3 { + margin: 0 0 0.55rem; + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 560; + letter-spacing: -0.01em; +} + +.card p { + margin: 0; + font-size: 0.9rem; + line-height: 1.65; + color: var(--text-dim); +} + +/* ---------- Versus ---------- */ + +.versus { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + overflow: hidden; +} + +.versus-head, +.versus-row { + display: grid; + grid-template-columns: minmax(7rem, 0.55fr) minmax(0, 1fr) minmax(0, 1.2fr); + gap: 1.25rem; + padding: 0.95rem 1.5rem; + align-items: baseline; +} + +.versus-head { + border-bottom: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.versus-head .v-docs { + color: var(--text-faint); +} + +.versus-head .v-guide { + color: var(--green); +} + +.versus-row { + border-bottom: 1px solid var(--line-soft); + font-size: 0.92rem; +} + +.versus-row:last-child { + border-bottom: 0; +} + +.v-label { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-faint); + letter-spacing: 0.04em; +} + +.v-docs { + color: var(--text-faint); +} + +.v-guide { + color: var(--text); + position: relative; + padding-left: 0.9rem; +} + +.v-guide::before { + content: ""; + position: absolute; + left: 0; + top: 0.3em; + bottom: 0.3em; + width: 2px; + border-radius: 2px; + background: var(--green-vivid); + box-shadow: 0 0 12px var(--green-glow); +} + +/* ---------- Progress panel ---------- */ + +.progress-panel { + display: flex; + align-items: center; + gap: 2rem; + margin: 0 0 3.5rem; + padding: 1.5rem 1.75rem; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel); +} + +.no-js .progress-panel { + display: none; +} + +.ring-wrap { + position: relative; + width: 104px; + height: 104px; + flex: none; +} + +.ring { + width: 104px; + height: 104px; + transform: rotate(-90deg); +} + +.ring circle { + fill: none; + stroke-width: 7; +} + +.ring-bg { + stroke: var(--line); +} + +.ring-fg { + stroke: var(--green-vivid); + stroke-linecap: round; + stroke-dasharray: 326.7; + stroke-dashoffset: 326.7; + filter: drop-shadow(0 0 6px var(--green-tint-45)); + transition: stroke-dashoffset 0.7s var(--ease-out); +} + +.ring-label { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; +} + +.ring-pct { + font-family: var(--font-display); + font-size: 1.55rem; + font-weight: 560; + line-height: 1; +} + +.ring-cap { + font-family: var(--font-mono); + font-size: 0.58rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-faint); + margin-top: 0.2rem; +} + +.progress-mid { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 2rem; + min-width: 0; +} + +.counts { + margin: 0; + font-size: 0.95rem; + color: var(--text-dim); + flex: 1 1 auto; +} + +.counts b { + color: var(--text); + font-weight: 700; + font-family: var(--font-mono); +} + +.counts-sep { + margin: 0 0.6rem; + color: var(--text-faint); +} + +.progress-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +#continue-btn { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.all-done { + color: var(--green); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +/* ---------- Subway ---------- */ + +.subway { + position: relative; +} + +.track { + position: absolute; + left: 15px; + top: 0; + bottom: 0; + width: 2px; + background: var(--line-soft); + border-radius: 2px; +} + +.track-fill { + width: 100%; + height: 0; + border-radius: 2px; + background: linear-gradient(180deg, var(--green-vivid), var(--green-tint-55)); + box-shadow: 0 0 14px var(--green-glow); + transition: height 0.2s linear; +} + +.level { + position: relative; + margin-bottom: 3rem; +} + +.level:last-child { + margin-bottom: 0; +} + +.level-head { + padding-left: 3.4rem; + margin-bottom: 1.4rem; +} + +.level-name { + margin: 0 0 0.35rem; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--green); +} + +.level-title { + margin: 0 0 0.4rem; + font-family: var(--font-display); + font-size: 1.6rem; + font-weight: 520; + letter-spacing: -0.01em; +} + +.level-sum { + margin: 0; + max-width: 36rem; + color: var(--text-dim); + font-size: 0.94rem; +} + +.stations { + list-style: none; + margin: 0; + padding: 0; +} + +.station { + position: relative; + padding-left: 3.4rem; + margin-bottom: 1.1rem; +} + +.node { + position: absolute; + left: 9px; + top: 2.05rem; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--node-ring); + background: var(--bg); + transition: + border-color 0.35s var(--ease-out), + box-shadow 0.35s var(--ease-out); +} + +.station[data-status="in-progress"] .node, +.station[data-status="done"] .node { + border-color: var(--green-vivid); + box-shadow: 0 0 14px var(--green-glow); +} + +.station[data-status="done"] .node::after { + content: ""; + position: absolute; + inset: 3px; + border-radius: 50%; + background: var(--green-vivid); + box-shadow: 0 0 8px var(--green-glow); +} + +.station-card { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + padding: 1.3rem 1.5rem 1.1rem; + transition: + border-color 0.3s var(--ease-out), + box-shadow 0.3s var(--ease-out); +} + +.station-card:hover { + border-color: var(--line-hover); +} + +.station[data-status="in-progress"] .station-card { + border-color: var(--green-line); +} + +.station[data-status="done"] .station-card { + border-color: var(--green-line); + box-shadow: var(--shadow-done); +} + +.station-head { + display: flex; + align-items: flex-start; + gap: 1.1rem; +} + +.station-num { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 300; + font-style: italic; + line-height: 1; + color: var(--text-faint); + flex: none; + min-width: 2.2rem; + padding-top: 0.15rem; +} + +.station[data-status="done"] .station-num, +.station[data-status="in-progress"] .station-num { + color: var(--green); +} + +.station-meta { + flex: 1; + min-width: 0; +} + +.station-title { + margin: 0 0 0.3rem; + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 560; + letter-spacing: -0.01em; +} + +.station-title a { + color: var(--text); + transition: color 0.2s; +} + +.station-title a:hover { + color: var(--green); + text-decoration: none; +} + +.station-tag { + margin: 0; + font-size: 0.88rem; + color: var(--text-dim); +} + +.station-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + flex: none; +} + +.station-time { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-faint); + letter-spacing: 0.05em; +} + +.pill { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.65rem; + border-radius: 999px; + border: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); + white-space: nowrap; +} + +.station[data-status="in-progress"] .pill { + color: var(--amber); + border-color: var(--amber-line); +} + +.station[data-status="done"] .pill { + color: var(--green); + border-color: var(--green-soft); +} + +.station-bar { + display: flex; + align-items: center; + gap: 0.9rem; + margin-top: 1rem; +} + +.bar { + flex: 1; + height: 3px; + border-radius: 3px; + background: var(--line-soft); + overflow: hidden; +} + +.bar i { + display: block; + height: 100%; + width: 0; + border-radius: 3px; + background: var(--green-vivid); + box-shadow: 0 0 8px var(--green-glow); + transition: width 0.5s var(--ease-out); +} + +.bar-count { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-faint); + flex: none; +} + +.station-toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-top: 0.9rem; + padding: 0.35rem 0.7rem; + border: 1px solid var(--line); + border-radius: 7px; + background: transparent; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.05em; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} + +.no-js .station-toggle { + display: none; +} + +.station-toggle:hover { + color: var(--text); + border-color: var(--line-strong); +} + +.station-toggle svg { + width: 12px; + height: 12px; + transition: rotate 0.3s var(--ease-out); +} + +.station-toggle[aria-expanded="true"] svg { + rotate: 180deg; +} + +.lessons { + list-style: none; + margin: 0.9rem 0 0; + padding: 0.9rem 0 0; + border-top: 1px solid var(--line-soft); +} + +.lessons[hidden] { + display: none; +} + +.lesson { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.42rem 0; + font-size: 0.9rem; +} + +.check { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + flex: none; + border: 0; + background: transparent; + color: var(--green-vivid); + cursor: pointer; + padding: 0; +} + +.check::before { + content: ""; + position: absolute; + width: 19px; + height: 19px; + border: 1px solid var(--line-strong); + border-radius: 5px; + transition: border-color 0.25s, box-shadow 0.25s; +} + +.no-js .check { + display: none; +} + +.check svg { + width: 13px; + height: 13px; +} + +.check-mark, +.check-dash { + stroke-dasharray: 20; + stroke-dashoffset: 20; + transition: stroke-dashoffset 0.35s var(--ease-out); +} + +.check[aria-checked="true"]::before { + border-color: var(--green-vivid); + box-shadow: 0 0 10px var(--green-tint-18); +} + +.check[aria-checked="true"] .check-mark { + stroke-dashoffset: 0; +} + +.check[aria-checked="mixed"] { + color: var(--amber); +} + +.check[aria-checked="mixed"]::before { + border-color: var(--amber); +} + +.check[aria-checked="mixed"] .check-dash { + stroke-dashoffset: 0; +} + +.check:hover::before { + border-color: var(--green-vivid); +} + +.lesson-link { + color: var(--text-dim); + flex: 1; + min-width: 0; + transition: color 0.2s; +} + +.lesson-link:hover { + color: var(--text); + text-decoration: none; +} + +.lesson[data-status="done"] .lesson-link { + color: var(--text-faint); + text-decoration: line-through; + text-decoration-color: var(--green-soft); + text-decoration-thickness: 1px; +} + +.lesson-status { + flex: none; + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-faint); +} + +.lesson[data-status="in-progress"] .lesson-status { + color: var(--amber); +} + +.lesson[data-status="done"] .lesson-status { + color: var(--green); +} + +/* ---------- Steps ---------- */ + +.steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + counter-reset: step; +} + +.step { + position: relative; + min-width: 0; + padding: 1.6rem 1.4rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + transition: border-color 0.3s var(--ease-out), translate 0.3s var(--ease-out); +} + +.step:hover { + border-color: var(--green-line); + translate: 0 -3px; +} + +.step-num { + font-family: var(--font-display); + font-style: italic; + font-weight: 300; + font-size: 2rem; + color: var(--green); + line-height: 1; + display: block; + margin-bottom: 0.9rem; +} + +.step h3 { + margin: 0 0 0.5rem; + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 560; +} + +.step p { + margin: 0 0 1.1rem; + font-size: 0.88rem; + color: var(--text-dim); +} + +.code { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 0.65rem 0.55rem 0.85rem; + border: 1px solid var(--chip-line); + border-radius: 9px; + background: var(--chip-bg); + overflow: hidden; +} + +.code code { + flex: 1; + min-width: 0; + border: 0; + padding: 0; + font-size: 0.74rem; + color: var(--chip-text); + overflow-x: auto; + white-space: nowrap; + scrollbar-width: none; +} + +.code code::-webkit-scrollbar { + display: none; +} + +.copy { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex: none; + border: 1px solid var(--chip-line); + border-radius: 6px; + background: transparent; + color: var(--chip-dim); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.06em; + padding: 0.3rem 0.5rem; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} + +.copy svg { + width: 12px; + height: 12px; +} + +.copy:hover { + color: var(--chip-green); + border-color: var(--green-soft); +} + +.copy.copied { + color: var(--chip-green); + border-color: var(--green-vivid); +} + +.step-link { + font-family: var(--font-mono); + font-size: 0.82rem; + color: var(--green); +} + +/* ---------- Footer ---------- */ + +.footer { + margin-top: 4rem; + border-top: 1px solid var(--line-soft); + background: linear-gradient(180deg, var(--green-tint-03), transparent 40%); +} + +.footer-inner { + max-width: var(--wrap); + margin: 0 auto; + padding: 3rem 1.5rem 2rem; + display: grid; + grid-template-columns: 1.4fr 1fr 1fr 1fr; + gap: 2rem; +} + +.footer-logo { + height: 36px; + width: auto; + margin-bottom: 0.9rem; +} + +.footer-note { + margin: 0; + max-width: 18rem; + font-size: 0.84rem; + color: var(--text-faint); +} + +.footer-head { + margin: 0 0 0.9rem; + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--text-faint); +} + +.footer-col a { + display: block; + margin-bottom: 0.5rem; + font-size: 0.88rem; + color: var(--text-dim); +} + +.footer-col a:hover { + color: var(--green); +} + +.footer-version { + display: block; + margin-top: 0.9rem; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--green); +} + +.footer-base { + max-width: var(--wrap); + margin: 0 auto; + padding: 1.2rem 1.5rem 1.6rem; + border-top: 1px solid var(--line-soft); + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-faint); +} + +.footer-top:hover { + text-decoration: none; +} + +/* ---------- Reveal ---------- */ + +.js .reveal { + opacity: 0; + translate: 0 18px; + transition: + opacity 0.8s var(--ease-out) var(--d, 0s), + translate 0.8s var(--ease-out) var(--d, 0s); +} + +.js .reveal.in { + opacity: 1; + translate: 0 0; +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 62rem) { + .hero-grid { + grid-template-columns: 1fr; + gap: 2.6rem; + } + + .term-body { + min-height: 0; + } + + .cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .footer-inner { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 56rem) { + .nav:not(.nav-enhanced) .nav-inner { + flex-wrap: wrap; + } + + .nav-links { + order: 3; + width: 100%; + margin-left: 0; + flex-wrap: wrap; + gap: 0.25rem 1rem; + } + + .nav-enhanced .nav-links { + position: absolute; + top: 100%; + left: 0; + right: 0; + display: none; + width: auto; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 0.4rem 1rem 1rem; + border-bottom: 1px solid var(--line-soft); + background: var(--nav-menu); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .nav-enhanced .nav-links.open { + display: flex; + } + + .nav-enhanced .nav-links a { + padding: 0.7rem 0.5rem; + font-size: 0.95rem; + border-bottom: 1px solid var(--line-soft); + } + + .nav-enhanced .nav-links a:last-child { + border-bottom: 0; + } + + .nav-enhanced .nav-toggle { + display: inline-flex; + } + + .nav-links + .nav-actions { + margin-left: auto; + } + + .versus-head, + .versus-row { + grid-template-columns: 1fr; + gap: 0.3rem; + padding: 0.9rem 1.2rem; + } + + .versus-head { + display: none; + } + + .versus-row .v-docs::before { + content: "Docs · "; + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-faint); + } + + .versus-row .v-guide::before { + content: "Guide · "; + position: static; + width: auto; + background: none; + box-shadow: none; + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--green); + padding-left: 0; + } + + .versus-row .v-guide { + padding-left: 0; + color: var(--text); + } + + .progress-panel { + flex-direction: column; + align-items: flex-start; + gap: 1.3rem; + } + + .steps { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 30rem) { + .nav-cta { + display: none; + } +} + +@media (max-width: 40rem) { + .nav-inner { + gap: 0.9rem; + padding: 0.7rem 1rem; + } + + .nav-logo { + height: 26px; + } + + .nav-actions { + gap: 0.5rem; + } + + .btn-sm { + padding: 0.45rem 0.9rem; + } + + .hero { + padding-top: 3.5rem; + } + + .station { + padding-left: 2.6rem; + } + + .level-head { + padding-left: 2.6rem; + } + + .track { + left: 11px; + } + + .node { + left: 5px; + top: 1.9rem; + } + + .station-head { + flex-wrap: wrap; + } + + .station-side { + flex-direction: row; + align-items: center; + width: 100%; + justify-content: space-between; + } + + .cards { + grid-template-columns: minmax(0, 1fr); + } + + .hero-stats { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0 1.2rem; + } + + .stat { + border-right: 0; + margin-right: 0; + padding-right: 0; + padding-bottom: 0.6rem; + min-width: 0; + } + + .stat dd { + white-space: normal; + } + + .footer-inner { + grid-template-columns: 1fr; + gap: 1.6rem; + } +} + +/* ---------- Reduced motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .js .reveal { + opacity: 1; + translate: none; + } + + .t-tt.t-typing::after, + .t-line.t-caret .t-tt::after { + animation: none; + } +} diff --git a/scripts/website_templates/landing.html.j2 b/scripts/website_templates/landing.html.j2 new file mode 100644 index 0000000..1c7ba5b --- /dev/null +++ b/scripts/website_templates/landing.html.j2 @@ -0,0 +1,473 @@ + + + + + + {{ site_title }} — Master Claude Code in a Weekend + + + + + + + + + + + + + + + {# ============ Sticky nav ============ #} + + +
+ {# ============ Hero ============ #} +
+
+
+
+

~/claude-howto · a weekend curriculum

+ {% if version %}Synced to v{{ version }}{% endif %} +
+

+ Master Claude Code
in a Weekend +

+

+ Go from typing claude to orchestrating agents, hooks, + skills, and MCP servers — with visual tutorials, copy‑paste + templates, and a guided learning path. +

+ +
+
+
Modules
+
{{ module_count }}
+
+
+
Time
+
11–13 h
+
+
+
Languages
+
5
+
+
+
+ + +
+
+ + {# ============ Why / value ============ #} +
+

Why this guide

+

The docs tell you what.
This shows you how.

+
+
+ +

Visual by default

+

+ Mermaid diagrams show how each feature works under the hood — so + you understand why, not just how. +

+
+
+ +

Copy-paste templates

+

+ Production-ready slash commands, CLAUDE.md files, hook scripts, MCP + configs and plugin bundles — drop them into your project today. +

+
+
+ +

A guided path

+

+ Ten modules in the order a learner needs them — beginner to power + user in 11–13 hours. No more guessing what to learn next. +

+
+
+ +

Self-assessment built in

+

+ Run /self-assessment or /lesson-quiz inside + Claude Code to find your gaps and get a personalized path. +

+
+
+
+ + {# ============ Docs vs guide ============ #} +
+

Official docs vs this guide

+

Different jobs, same tool

+

+ This guide complements the + official docs + — they are the reference; this is the curriculum. +

+
+ +
+ Format + Reference documentation + Visual tutorials with Mermaid diagrams +
+
+ Depth + Feature descriptions + How it works under the hood +
+
+ Examples + Basic snippets + Production-ready templates you use immediately +
+
+ Structure + Feature-organized + Progressive learning path (beginner to advanced) +
+
+ Onboarding + Self-directed + Guided roadmap with time estimates +
+
+ Self-assessment + None + Interactive quizzes that build a personalized path +
+
+
+ + {# ============ Roadmap ============ #} +
+

The roadmap

+

Ten stations, one line

+

+ Three levels, {{ module_count }} modules, {{ lesson_count }} lessons. + Progress is saved in your browser — check lessons off as you go. +

+ +
+ +
+

+ 0 done + + 0 in progress + + {{ lesson_count }} not started +

+
+ Continue + + +
+
+
+ +
+ + {% for level in levels %} +
+
+

{{ level.name }}

+

{{ level.title }}

+

{{ level.summary }}

+
+
    + {% for module in level.modules %} +
  1. + +
    +
    + +
    +

    + {{ module.title }} +

    +

    {{ module.tagline }}

    +
    +
    + {{ module.time }} + Not started +
    +
    +
    + + 0/{{ module.lesson_count }} +
    + +
      + {% for lesson in module.lessons %} +
    • + + {{ lesson.title }} + Not started +
    • + {% endfor %} +
    +
    +
  2. + {% endfor %} +
+
+ {% endfor %} +
+
+ + {# ============ Get started ============ #} +
+

Get started

+

Fifteen minutes to your first workflow

+
    +
  1. + +

    Clone the repo

    +

    Everything is markdown — templates included, nothing to install.

    +
    + git clone {{ repo_url }}.git + +
    +
  2. +
  3. + +

    Take the self-assessment

    +

    Inside Claude Code, find your level and skip what you know.

    +
    + /self-assessment + +
    +
  4. +
  5. + +

    Follow the roadmap

    +

    Work through the modules at your level — progress is tracked above.

    + Start at your level → +
  6. +
+ +
+
+ + {# ============ Footer ============ #} + + + diff --git a/scripts/website_templates/landing.js b/scripts/website_templates/landing.js new file mode 100644 index 0000000..f30c669 --- /dev/null +++ b/scripts/website_templates/landing.js @@ -0,0 +1,639 @@ +/* claude-howto landing — progress tracking, terminal, scroll effects. + Vanilla JS, no dependencies. The page is fully usable without it: + every lesson link navigates, and interactive controls are hidden via + the `no-js` class on . */ +(function () { + "use strict"; + + var root = document.documentElement; + root.classList.remove("no-js"); + root.classList.add("js"); + + var STORAGE_KEY = "claude-howto.progress.v1"; + var VALID_STATUS = { "not-started": true, "in-progress": true, "done": true }; + var STATUS_LABEL = { + "not-started": "Not started", + "in-progress": "In progress", + done: "Done", + }; + + var reducedMotion = false; + try { + reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + } catch (e) { + /* matchMedia unavailable — treat as no preference */ + } + + /* ================= Theme (shared with the docs pages) ================= + Same localStorage key and `dark` class on as page.html.j2. + The pre-paint inline script already applied the initial theme; this + wires the toggle and follows the OS scheme while nothing is saved. */ + + var THEME_KEY = "claude-howto-theme"; + var themeToggle = document.getElementById("theme-toggle"); + + function currentTheme() { + return root.classList.contains("dark") ? "dark" : "light"; + } + + function paintThemeToggle() { + if (!themeToggle) return; + themeToggle.setAttribute( + "aria-label", + currentTheme() === "dark" + ? "Switch to light theme" + : "Switch to dark theme" + ); + } + + function savedTheme() { + try { + return window.localStorage.getItem(THEME_KEY); + } catch (e) { + return null; + } + } + + function applyTheme(theme, animate) { + if (animate && !reducedMotion) { + root.classList.add("theme-anim"); + window.setTimeout(function () { + root.classList.remove("theme-anim"); + }, 350); + } + root.classList.toggle("dark", theme === "dark"); + paintThemeToggle(); + } + + if (themeToggle) { + themeToggle.addEventListener("click", function () { + var next = currentTheme() === "dark" ? "light" : "dark"; + applyTheme(next, true); + try { + window.localStorage.setItem(THEME_KEY, next); + } catch (e) {} + }); + } + paintThemeToggle(); + + /* No saved choice: follow prefers-color-scheme changes live. */ + try { + var themeMq = window.matchMedia("(prefers-color-scheme: dark)"); + var followOsTheme = function (e) { + if (!savedTheme()) applyTheme(e.matches ? "dark" : "light", true); + }; + if (themeMq.addEventListener) { + themeMq.addEventListener("change", followOsTheme); + } else if (themeMq.addListener) { + themeMq.addListener(followOsTheme); + } + } catch (e) {} + + /* ================= Progress store ================= */ + + var mem = { v: 1, lessons: {} }; + var persist = true; + + function loadState() { + if (!persist) return; + try { + var raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return; + var data = JSON.parse(raw); + if (data && data.v === 1 && data.lessons && typeof data.lessons === "object") { + mem.lessons = data.lessons; + } + } catch (e) { + persist = false; + } + } + + function saveState() { + if (!persist) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(mem)); + } catch (e) { + persist = false; + } + } + + var lessonEls = Array.prototype.slice.call( + document.querySelectorAll(".lesson[data-lesson]") + ); + var stationEls = Array.prototype.slice.call( + document.querySelectorAll(".station[data-module]") + ); + var knownIds = {}; + lessonEls.forEach(function (el) { + knownIds[el.getAttribute("data-lesson")] = true; + }); + + function lessonStatus(id) { + var rec = mem.lessons[id]; + return rec && VALID_STATUS[rec.s] ? rec.s : "not-started"; + } + + function setStatus(id, status) { + if (!knownIds[id]) return; + mem.lessons[id] = { s: status, t: Date.now() }; + saveState(); + } + + function pruneUnknown() { + var changed = false; + Object.keys(mem.lessons).forEach(function (id) { + if (!knownIds[id]) { + delete mem.lessons[id]; + changed = true; + } + }); + if (changed) saveState(); + } + + /* Mark a lesson in progress when the user navigates to it. Shared by + lesson links, module title links and the Continue/Start button. */ + function markStarted(lessonEl) { + if (!lessonEl) return; + var id = lessonEl.getAttribute("data-lesson"); + if (lessonStatus(id) === "not-started") { + setStatus(id, "in-progress"); + paintAll(); + } + } + + /* Mark on left-click (incl. ctrl/cmd-click — navigation still proceeds) + and on middle-click, which fires `auxclick` with button 1. */ + function bindMarkStarted(el, getLessonEl) { + el.addEventListener("click", function () { + markStarted(getLessonEl()); + }); + el.addEventListener("auxclick", function (e) { + if (e.button === 1) markStarted(getLessonEl()); + }); + } + + /* ================= Painting ================= */ + + function paintLesson(el) { + var id = el.getAttribute("data-lesson"); + var status = lessonStatus(id); + el.setAttribute("data-status", status); + var check = el.querySelector(".check"); + if (check) { + check.setAttribute( + "aria-checked", + status === "done" ? "true" : status === "in-progress" ? "mixed" : "false" + ); + } + var label = el.querySelector("[data-lstatus]"); + if (label) label.textContent = STATUS_LABEL[status]; + } + + function paintStation(station) { + var rows = station.querySelectorAll(".lesson[data-lesson]"); + var total = rows.length; + var done = 0; + var started = 0; + for (var i = 0; i < total; i++) { + var s = lessonStatus(rows[i].getAttribute("data-lesson")); + if (s === "done") { + done++; + started++; + } else if (s === "in-progress") { + started++; + } + } + var status = + total > 0 && done === total + ? "done" + : started > 0 + ? "in-progress" + : "not-started"; + station.setAttribute("data-status", status); + var pill = station.querySelector("[data-pill]"); + if (pill) pill.textContent = STATUS_LABEL[status]; + var count = station.querySelector("[data-count]"); + if (count) count.textContent = done + "/" + total; + var bar = station.querySelector("[data-bar]"); + if (bar) bar.style.width = total ? (done / total) * 100 + "%" : "0%"; + } + + var RING_R = 52; + var RING_C = 2 * Math.PI * RING_R; + var continueTarget = null; + + function paintOverall() { + var total = lessonEls.length; + var done = 0; + var inProgress = 0; + var continueEl = null; + var continueTime = -1; + var firstTodo = null; + + lessonEls.forEach(function (el) { + var id = el.getAttribute("data-lesson"); + var s = lessonStatus(id); + if (s === "done") { + done++; + } else if (s === "in-progress") { + inProgress++; + var t = mem.lessons[id] && mem.lessons[id].t ? mem.lessons[id].t : 0; + if (t > continueTime) { + continueTime = t; + continueEl = el; + } + } else if (!firstTodo) { + firstTodo = el; + } + }); + + var pct = total ? Math.round((done / total) * 100) : 0; + var ring = document.getElementById("ring-fg"); + if (ring) { + ring.style.strokeDashoffset = String(RING_C * (1 - done / (total || 1))); + } + var pctEl = document.getElementById("ring-pct"); + if (pctEl) pctEl.textContent = pct + "%"; + var doneEl = document.getElementById("c-done"); + if (doneEl) doneEl.textContent = String(done); + var progEl = document.getElementById("c-prog"); + if (progEl) progEl.textContent = String(inProgress); + var todoEl = document.getElementById("c-todo"); + if (todoEl) todoEl.textContent = String(total - done - inProgress); + + var continueBtn = document.getElementById("continue-btn"); + var allDone = document.getElementById("all-done"); + var target = continueEl || firstTodo; + continueTarget = target; + if (continueBtn && allDone) { + if (target && done < total) { + var link = target.querySelector(".lesson-link"); + var title = link ? link.textContent.trim() : ""; + var station = target.closest(".station"); + var modTitle = ""; + if (station) { + var t = station.querySelector(".station-title"); + modTitle = t ? t.textContent.trim() : ""; + } + var verb = done + inProgress === 0 ? "Start" : "Continue"; + continueBtn.hidden = false; + allDone.hidden = true; + if (link) continueBtn.setAttribute("href", link.getAttribute("href")); + continueBtn.textContent = + verb + ": " + (modTitle ? modTitle + " · " : "") + title; + } else { + continueBtn.hidden = true; + allDone.hidden = false; + } + } + paintTrack(); + } + + function paintTrack() { + var fill = document.getElementById("track-fill"); + var subway = document.getElementById("subway"); + if (!fill || !subway) return; + + var rect = subway.getBoundingClientRect(); + if (rect.height <= 0) return; + + // Furthest started station keeps the line lit even above the scroll mark. + var statusP = 0; + stationEls.forEach(function (st) { + if (st.getAttribute("data-status") === "not-started") return; + var node = st.querySelector(".node") || st; + var y = node.getBoundingClientRect().top - rect.top; + if (y > 0) statusP = Math.max(statusP, Math.min(1, y / rect.height)); + }); + + var scrollP = Math.min( + 1, + Math.max(0, (window.innerHeight * 0.8 - rect.top) / rect.height) + ); + + fill.style.height = Math.max(scrollP, statusP) * 100 + "%"; + } + + function paintAll() { + lessonEls.forEach(paintLesson); + stationEls.forEach(paintStation); + paintOverall(); + } + + /* ================= Events ================= */ + + lessonEls.forEach(function (el) { + var id = el.getAttribute("data-lesson"); + + var check = el.querySelector(".check"); + if (check) { + check.addEventListener("click", function () { + var s = lessonStatus(id); + setStatus(id, s === "done" ? "in-progress" : "done"); + paintAll(); + }); + } + + var link = el.querySelector(".lesson-link"); + if (link) { + bindMarkStarted(link, function () { + return el; + }); + } + }); + + function setExpanded(station, expanded) { + var toggle = station.querySelector(".station-toggle"); + var list = station.querySelector(".lessons"); + if (!toggle || !list) return; + toggle.setAttribute("aria-expanded", String(expanded)); + list.hidden = !expanded; + } + + stationEls.forEach(function (station) { + var toggle = station.querySelector(".station-toggle"); + if (!toggle) return; + + // Collapse by default; in-progress modules are re-expanded after the + // first paint once their status is known (see init below). + setExpanded(station, false); + + toggle.addEventListener("click", function () { + var open = toggle.getAttribute("aria-expanded") === "true"; + setExpanded(station, !open); + }); + + // The module title links to the module page; treat it like opening its + // first lesson (Overview). + var titleLink = station.querySelector(".station-title a"); + if (titleLink) { + bindMarkStarted(titleLink, function () { + return station.querySelector(".lesson[data-lesson]"); + }); + } + }); + + var continueBtnEl = document.getElementById("continue-btn"); + if (continueBtnEl) { + bindMarkStarted(continueBtnEl, function () { + return continueTarget; + }); + } + + var resetBtn = document.getElementById("reset-btn"); + if (resetBtn) { + resetBtn.addEventListener("click", function () { + if (!window.confirm("Reset all lesson progress? This cannot be undone.")) { + return; + } + mem.lessons = {}; + saveState(); + paintAll(); + }); + } + + /* Cross-tab sync + bfcache restore. */ + window.addEventListener("storage", function (e) { + if (e.key === THEME_KEY && e.newValue) { + applyTheme(e.newValue, true); + return; + } + if (e.key !== STORAGE_KEY) return; + mem.lessons = {}; + persist = true; + loadState(); + pruneUnknown(); + paintAll(); + }); + + window.addEventListener("pageshow", function () { + mem.lessons = {}; + loadState(); + pruneUnknown(); + paintAll(); + }); + + var scrollTicking = false; + window.addEventListener( + "scroll", + function () { + if (scrollTicking) return; + scrollTicking = true; + window.requestAnimationFrame(function () { + paintTrack(); + scrollTicking = false; + }); + }, + { passive: true } + ); + window.addEventListener("resize", paintTrack); + + /* ================= Terminal typing ================= */ + + function runTerminal() { + var term = document.getElementById("terminal"); + if (!term || reducedMotion) return; + + var lines = Array.prototype.slice.call(term.querySelectorAll(".t-line")); + var typed = lines.map(function (line) { + var tt = line.querySelector(".t-tt"); + var text = tt ? tt.textContent : ""; + return { line: line, tt: tt, text: text, isCmd: !!tt }; + }); + + function reset() { + typed.forEach(function (item) { + item.line.style.visibility = "hidden"; + item.line.classList.remove("t-caret"); + if (item.tt) { + item.tt.textContent = ""; + item.tt.classList.remove("t-typing"); + } + }); + } + + var delays = []; + function later(fn, ms) { + delays.push(window.setTimeout(fn, ms)); + } + + function play() { + reset(); + var t = 350; + typed.forEach(function (item) { + if (item.isCmd) { + // Command lines type character by character. + var text = item.text; + var start = t; + for (var i = 0; i <= text.length; i++) { + (function (it, ch) { + later(function () { + it.line.style.visibility = "visible"; + it.tt.textContent = text.slice(0, ch); + it.tt.classList.add("t-typing"); + }, start + ch * 38); + })(item, i); + } + t += text.length * 38 + 420; + (function (it) { + later(function () { + it.tt.classList.remove("t-typing"); + }, t - 380); + })(item); + } else { + // Output lines appear at once. + (function (it, at) { + later(function () { + it.line.style.visibility = "visible"; + }, at); + })(item, t); + t += 260; + } + }); + // Leave the caret blinking on the final prompt, hold, then loop. + var last = typed[typed.length - 1]; + if (last && last.tt) { + later(function () { + last.line.classList.add("t-caret"); + }, t); + } + later(play, t + 6500); + } + play(); + } + + /* ================= Reveal on scroll ================= */ + + var revealEls = Array.prototype.slice.call( + document.querySelectorAll(".reveal") + ); + if ("IntersectionObserver" in window && !reducedMotion) { + var io = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add("in"); + io.unobserve(entry.target); + } + }); + }, + { threshold: 0.12, rootMargin: "0px 0px -6% 0px" } + ); + revealEls.forEach(function (el) { + io.observe(el); + }); + } else { + revealEls.forEach(function (el) { + el.classList.add("in"); + }); + } + + /* ================= Mobile nav ================= */ + + var navToggle = document.getElementById("nav-toggle"); + var navMenu = document.getElementById("nav-menu"); + if (navToggle && navMenu) { + navToggle.addEventListener("click", function () { + var open = navMenu.classList.toggle("open"); + navToggle.setAttribute("aria-expanded", String(open)); + }); + navMenu.addEventListener("click", function (e) { + if (e.target.closest("a")) { + navMenu.classList.remove("open"); + navToggle.setAttribute("aria-expanded", "false"); + } + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && navMenu.classList.contains("open")) { + navMenu.classList.remove("open"); + navToggle.setAttribute("aria-expanded", "false"); + navToggle.focus(); + } + }); + document.getElementById("top").classList.add("nav-enhanced"); + } + + /* ================= Copy buttons ================= */ + + var copyStatus = document.getElementById("copy-status"); + var copyAttempt = 0; + + function legacyCopy(text) { + var ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + try { + ta.select(); + return document.execCommand("copy"); + } catch (e) { + return false; + } finally { + document.body.removeChild(ta); + } + } + + Array.prototype.slice + .call(document.querySelectorAll("[data-copy]")) + .forEach(function (btn) { + var label = btn.querySelector(".copy-label"); + var originalLabel = label ? label.textContent : ""; + var feedbackTimer = null; + btn.addEventListener("click", function () { + var text = btn.getAttribute("data-copy") || ""; + var attempt = ++copyAttempt; + if (feedbackTimer !== null) window.clearTimeout(feedbackTimer); + feedbackTimer = null; + if (label) label.textContent = originalLabel; + btn.classList.remove("copied"); + if (copyStatus) copyStatus.textContent = ""; + function feedback(copied) { + if (attempt !== copyAttempt) return; + btn.classList.toggle("copied", copied); + if (copyStatus) { + copyStatus.textContent = copied + ? "Copied to clipboard" + : "Unable to copy to clipboard"; + } + if (label) { + label.textContent = copied ? "Copied" : "Copy failed"; + feedbackTimer = window.setTimeout(function () { + label.textContent = originalLabel; + btn.classList.remove("copied"); + feedbackTimer = null; + }, 1400); + } + } + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + navigator.clipboard.writeText(text).then( + function () { feedback(true); }, + function () { + if (attempt === copyAttempt) feedback(legacyCopy(text)); + } + ); + } catch (e) { + feedback(legacyCopy(text)); + } + } else { + feedback(legacyCopy(text)); + } + }); + }); + + /* ================= Init ================= */ + + loadState(); + pruneUnknown(); + paintAll(); + stationEls.forEach(function (station) { + if (station.getAttribute("data-status") === "in-progress") { + setExpanded(station, true); + } + }); + paintTrack(); + runTerminal(); +})(); diff --git a/scripts/website_templates/roadmap.json b/scripts/website_templates/roadmap.json new file mode 100644 index 0000000..a4e91c7 --- /dev/null +++ b/scripts/website_templates/roadmap.json @@ -0,0 +1,188 @@ +{ + "levels": [ + { + "id": "beginner", + "name": "Level 1 — Beginner", + "title": "Getting started", + "summary": "Get productive on day one: commands, memory, safe rewinds and the CLI basics.", + "modules": [ + { + "id": "slash-commands", + "number": "01", + "title": "Slash Commands", + "source": "01-slash-commands/README.md", + "time": "30 min", + "tagline": "Built-in commands, custom commands and team shortcuts.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "built-in", "title": "Built-in commands reference", "heading": "Built-in Commands Reference"}, + {"id": "custom", "title": "Custom commands (now skills)", "heading": "Custom Commands (Now Skills)"}, + {"id": "mcp-prompts", "title": "MCP prompts as commands", "heading": "MCP Prompts as Commands"}, + {"id": "create", "title": "Create your own commands", "heading": "Creating Your Own Commands"}, + {"id": "best-practices", "title": "Best practices", "heading": "Best Practices"} + ] + }, + { + "id": "memory", + "number": "02", + "title": "Memory", + "source": "02-memory/README.md", + "time": "45 min", + "tagline": "CLAUDE.md, rules and auto memory that persist across sessions.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "quick-start", "title": "Quick start: initialize memory", "heading": "Quick Start: Initializing Memory"}, + {"id": "hierarchy", "title": "The memory hierarchy", "heading": "Memory Hierarchy in Claude Code"}, + {"id": "rules", "title": "Modular rules system", "heading": "Modular Rules System"}, + {"id": "auto-memory", "title": "Auto memory", "heading": "Auto Memory"} + ] + }, + { + "id": "checkpoints", + "number": "08", + "title": "Checkpoints", + "source": "08-checkpoints/README.md", + "time": "45 min", + "tagline": "Try bold ideas and rewind in one keystroke.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "accessing", "title": "Accessing checkpoints", "heading": "Accessing Checkpoints"}, + {"id": "rewind", "title": "Rewind options", "heading": "Rewind Options"}, + {"id": "patterns", "title": "Workflow patterns", "heading": "Workflow Patterns"}, + {"id": "git", "title": "Checkpoints and Git", "heading": "Integration with Git"} + ] + }, + { + "id": "cli", + "number": "10", + "title": "CLI", + "source": "10-cli/README.md", + "time": "30 min", + "tagline": "Interactive and print mode, flags and scripting.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "commands", "title": "CLI commands", "heading": "CLI Commands"}, + {"id": "flags", "title": "Core flags", "heading": "Core Flags"}, + {"id": "output", "title": "Output and formats", "heading": "Output & Format"}, + {"id": "use-cases", "title": "High-value use cases", "heading": "High-Value Use Cases"} + ] + } + ] + }, + { + "id": "intermediate", + "name": "Level 2 — Intermediate", + "title": "Building workflows", + "summary": "Automate with skills and hooks, then connect live data and delegate to subagents.", + "modules": [ + { + "id": "skills", + "number": "03", + "title": "Skills", + "source": "03-skills/README.md", + "time": "1 hour", + "tagline": "Reusable expertise Claude loads only when it needs it.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "progressive-disclosure", "title": "Progressive disclosure", "heading": "How Skills Work: Progressive Disclosure"}, + {"id": "create", "title": "Create custom skills", "heading": "Creating Custom Skills"}, + {"id": "invocation", "title": "Control skill invocation", "heading": "Controlling Skill Invocation"}, + {"id": "subagents", "title": "Run skills in subagents", "heading": "Running Skills in Subagents"}, + {"id": "manage", "title": "Manage skills", "heading": "Managing Skills"} + ] + }, + { + "id": "hooks", + "number": "06", + "title": "Hooks", + "source": "06-hooks/README.md", + "time": "1 hour", + "tagline": "Quality gates and automation on every tool call and event.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "configuration", "title": "Configuration", "heading": "Configuration"}, + {"id": "types", "title": "Hook types", "heading": "Hook Types"}, + {"id": "events", "title": "Hook events", "heading": "Hook Events"}, + {"id": "io", "title": "Hook input and output", "heading": "Hook Input and Output"}, + {"id": "prompt-hooks", "title": "Prompt-based hooks", "heading": "Prompt-Based Hooks"}, + {"id": "debugging", "title": "Debugging hooks", "heading": "Debugging"} + ] + }, + { + "id": "mcp", + "number": "05", + "title": "MCP", + "source": "05-mcp/README.md", + "time": "1 hour", + "tagline": "Connect GitHub, databases and APIs as live tools.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "install", "title": "Installation methods", "heading": "MCP Installation Methods"}, + {"id": "scopes", "title": "MCP scopes", "heading": "MCP Scopes"}, + {"id": "resources", "title": "Resources via @ mentions", "heading": "MCP Resources via @ Mentions"}, + {"id": "tool-search", "title": "MCP tool search", "heading": "MCP Tool Search"}, + {"id": "serve", "title": "Claude as an MCP server", "heading": "Claude as MCP Server (`claude mcp serve`)"} + ] + }, + { + "id": "subagents", + "number": "04", + "title": "Subagents", + "source": "04-subagents/README.md", + "time": "1.5 hours", + "tagline": "Delegate to specialists with their own context and tools.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "configuration", "title": "Configuration", "heading": "Configuration"}, + {"id": "built-in", "title": "Built-in subagents", "heading": "Built-in Subagents"}, + {"id": "chaining", "title": "Chaining subagents", "heading": "Chaining Subagents"}, + {"id": "background", "title": "Background subagents", "heading": "Background Subagents"}, + {"id": "worktree", "title": "Worktree isolation", "heading": "Worktree Isolation"}, + {"id": "teams", "title": "Agent teams", "heading": "Agent Teams (Experimental)"} + ] + } + ] + }, + { + "id": "advanced", + "name": "Level 3 — Advanced", + "title": "Power user", + "summary": "Plan, run headless and on a schedule, then package everything as a plugin for your team.", + "modules": [ + { + "id": "advanced-features", + "number": "09", + "title": "Advanced Features", + "source": "09-advanced-features/README.md", + "time": "2–3 hours", + "tagline": "Planning, auto mode, permissions, headless runs and more.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "planning", "title": "Planning mode", "heading": "Planning Mode"}, + {"id": "thinking", "title": "Extended thinking", "heading": "Extended Thinking"}, + {"id": "auto-mode", "title": "Auto mode", "heading": "Auto Mode"}, + {"id": "scheduled", "title": "Scheduled tasks", "heading": "Scheduled Tasks"}, + {"id": "permissions", "title": "Permission modes", "heading": "Permission Modes"}, + {"id": "headless", "title": "Headless mode", "heading": "Headless Mode"} + ] + }, + { + "id": "plugins", + "number": "07", + "title": "Plugins", + "source": "07-plugins/README.md", + "time": "2 hours", + "tagline": "Bundle commands, agents, hooks and MCP into one install.", + "lessons": [ + {"id": "overview", "title": "Overview", "heading": "Overview"}, + {"id": "architecture", "title": "Plugin architecture", "heading": "Plugin Architecture"}, + {"id": "structure", "title": "Plugin definition structure", "heading": "Plugin Definition Structure"}, + {"id": "marketplace", "title": "Plugin marketplace", "heading": "Plugin Marketplace"}, + {"id": "testing", "title": "Test a plugin", "heading": "Testing a Plugin"}, + {"id": "publishing", "title": "Publish a plugin", "heading": "Publishing a Plugin"} + ] + } + ] + } + ] +}