Visual by default
++ Mermaid diagrams show how each feature works under the hood — so + you understand why, not just how. +
+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 @@ + + +
+ + +~/claude-howto · a weekend curriculum
+ {% if version %}Synced to v{{ version }}{% endif %} +
+ Go from typing claude to orchestrating agents, hooks,
+ skills, and MCP servers — with visual tutorials, copy‑paste
+ templates, and a guided learning path.
+
Why this guide
++ Mermaid diagrams show how each feature works under the hood — so + you understand why, not just how. +
++ Production-ready slash commands, CLAUDE.md files, hook scripts, MCP + configs and plugin bundles — drop them into your project today. +
++ Ten modules in the order a learner needs them — beginner to power + user in 11–13 hours. No more guessing what to learn next. +
+
+ Run /self-assessment or /lesson-quiz inside
+ Claude Code to find your gaps and get a personalized path.
+
Official docs vs this guide
++ This guide complements the + official docs + — they are the reference; this is the curriculum. +
+The roadmap
++ 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 +
+{{ level.name }}
+{{ level.summary }}
+Get started
+Everything is markdown — templates included, nothing to install.
+git clone {{ repo_url }}.git
+
+ Inside Claude Code, find your level and skip what you know.
+/self-assessment
+
+ Work through the modules at your level — progress is tracked above.
+ Start at your level → +