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"} + ] + } + ] + } + ] +}