From a8ff2c475f0943ffc685ad77c3d438dcf2eea934 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 4 Jul 2026 14:39:45 -0500 Subject: [PATCH] site: add a sticky docs sidebar for a first-class desktop layout Two-column desktop layout (>=1024px): a sticky left page tree fills what was dead margin, with the examples expanded to their recipes and the active prose page's headings nested beneath it. Heading anchors scroll in-page without touching the routing hash. The header's top nav is hidden on desktop (the tree replaces it) and restored on mobile, where the sidebar collapses away. build-site nests the recipe list under the Examples manifest entry so the tree can render them. Verified with headless screenshots + click tests at 1400/1600 (nav + heading scroll) and 700px (mobile fallback). Co-Authored-By: Claude Opus 4.8 --- scripts/build-site.mjs | 2 +- site/app.js | 87 ++++++++++++++++++++++++++++++++++++++++++ site/index.html | 1 + site/style.css | 76 ++++++++++++++++++++++++++++++++++-- 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index 0a358cf..5519410 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -251,7 +251,7 @@ async function main() { "", ].join("\n"); await writeFile(path.join(outDocsDir, "examples", "index.md"), indexMd, "utf8"); - pages.push({ path: "examples/index.md", title: "Examples" }); + pages.push({ path: "examples/index.md", title: "Examples", children: exampleEntries }); } // Stable nav order: README homepage, roadmap, changelog, examples, then the rest. diff --git a/site/app.js b/site/app.js index d215cbd..8c61b94 100644 --- a/site/app.js +++ b/site/app.js @@ -1,4 +1,5 @@ const navEl = document.getElementById("nav"); +const sidebarEl = document.getElementById("sidebar"); const contentEl = document.getElementById("content"); const themeToggle = document.querySelector(".theme-toggle"); @@ -97,6 +98,89 @@ function renderNav(pages, activePath) { .join(""); } +function slugify(text) { + return String(text) + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +function pageHref(docPath) { + return "#" + normalizeDocPath(docPath).split("/").map(encodeURIComponent).join("/"); +} + +function sidebarLabel(page) { + return normalizeDocPath(page.path) === "index.md" ? "Overview" : page.title || page.path; +} + +// The left docs tree: top-level pages, examples expanded to their recipes, and +// (populated after the doc renders) the active page's own headings. +function renderSidebar(pages, activePath) { + const items = pages + .map((p) => { + const path = normalizeDocPath(p.path); + const active = path === activePath; + const kids = Array.isArray(p.children) ? p.children : []; + let html = `
  • ${escapeHtml( + sidebarLabel(p), + )}`; + if (kids.length) { + html += + `
      ` + + kids + .map((c) => { + const cp = normalizeDocPath(c.path); + const ca = cp === activePath; + return `
    • ${escapeHtml( + c.title || cp, + )}${ca ? '
        ' : ""}
      • `; + }) + .join("") + + `
      `; + } else if (active) { + html += `
        `; + } + return html + `
      • `; + }) + .join(""); + sidebarEl.innerHTML = ``; +} + +// Fill the active page's heading anchors (skipped when a page has <2 headings, +// e.g. the recipe pages, whose place in the tree is enough). +function renderHeadingToc() { + const toc = sidebarEl.querySelector(".s-toc"); + if (!toc) return; + const heads = [...contentEl.querySelectorAll("h2, h3")]; + if (heads.length < 2) { + toc.remove(); + return; + } + toc.innerHTML = heads + .map((h) => { + if (!h.id) h.id = slugify(h.textContent); + const sub = h.tagName === "H3" ? " s-toc-sub" : ""; + return `
      • ${escapeHtml( + h.textContent, + )}
      • `; + }) + .join(""); +} + +// Heading anchors scroll in-page without touching location.hash (the hash is +// the SPA's doc route, so a real #id anchor would break navigation). +sidebarEl.addEventListener("click", (e) => { + const a = e.target?.closest?.("a[data-target]"); + if (!a) return; + e.preventDefault(); + document.getElementById(a.getAttribute("data-target"))?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); +}); + function installContentLinkHandler() { contentEl.addEventListener("click", (e) => { const a = e.target?.closest?.("a"); @@ -154,6 +238,7 @@ async function main() { async function render() { const activePath = getSelectedPath() || defaultPath; renderNav(pages, activePath); + renderSidebar(pages, activePath); if (!activePath) { contentEl.innerHTML = `

        No docs yet.

        `; @@ -165,6 +250,8 @@ async function main() { const html = globalThis.marked.parse(md); contentEl.innerHTML = html; globalThis.Prism?.highlightAllUnder(contentEl); + renderHeadingToc(); + window.scrollTo(0, 0); for (const a of navEl.querySelectorAll("a")) { const href = decodeURIComponent((a.getAttribute("href") || "").slice(1)); diff --git a/site/index.html b/site/index.html index 1feb6c3..9415761 100644 --- a/site/index.html +++ b/site/index.html @@ -43,6 +43,7 @@
        +
        diff --git a/site/style.css b/site/style.css index e508ad6..e4fd35b 100644 --- a/site/style.css +++ b/site/style.css @@ -231,11 +231,81 @@ a:hover { padding: 28px 32px; } -/* Desktop: widen the panel so code stops clipping. Keep one width for prose and - code so text fills the card instead of stranding a narrow column beside a gap. */ +/* Docs sidebar (desktop only — a sticky page tree in what would be dead margin) */ +.sidebar { + display: none; +} +.sidebar ul { + list-style: none; + margin: 0; + padding: 0; +} +.sidebar a { + display: block; + padding: 4px 10px; + border-radius: 6px; + color: var(--muted); + text-decoration: none; + border-left: 2px solid transparent; + line-height: 1.4; +} +.sidebar a:hover { + color: var(--text); + background: var(--codebg); +} +.sidebar .s-page { + color: var(--text); + font-weight: 500; + margin-top: 2px; +} +.sidebar .s-children { + margin: 2px 0 8px; +} +.sidebar .s-child { + padding-left: 22px; + font-size: 12.5px; +} +.sidebar .s-toc { + margin: 2px 0 6px; +} +.sidebar .s-toc-link { + padding-left: 22px; + font-size: 12px; +} +.sidebar .s-toc-sub { + padding-left: 34px; +} +.sidebar a.active { + color: var(--text); + font-weight: 600; + background: var(--accent); + border-left-color: var(--link); +} + @media (min-width: 1024px) { .main { - max-width: 940px; + max-width: 1180px; + display: grid; + grid-template-columns: 216px minmax(0, 1fr); + gap: 36px; + align-items: start; + } + .sidebar { + display: block; + position: sticky; + top: 64px; + align-self: start; + max-height: calc(100vh - 84px); + overflow-y: auto; + font-size: 13px; + padding-bottom: 16px; + } + .content { + min-width: 0; + } + /* the page tree replaces the header's top nav on desktop */ + .header .nav { + display: none; } } -- 2.51.2