From 041af3379d2446378e75d99f46ae859f3142db97 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:33:38 -0400 Subject: [PATCH] feat(site): a header on every page, and navigation for the docs (phase 5) Every page gets the same header: the calculator, the docs, the example report, and GitHub, with a theme toggle. The doc pages get a grouped docs sidebar, an "On this page" contents list that follows the section in view, a link on every h2 and h3 that copies itself, previous and next links, an "Edit on GitHub" link, and copy buttons on code blocks. All of the navigation is HTML written by build_site.py, so it works with scripts off and check_site_links.mjs verifies every link it adds. The renderer now also returns each doc's headings and per-section text (the search index in the next phase reads the latter), emits the heading links itself rather than through raw HTML, and turns table alignment into classes so no page carries an inline style. site/site.js adds only conveniences. theme.js applies a remembered theme before paint, so a dark choice does not flash light. Form borders and the histogram's bars now meet 3:1 contrast in both themes (part of #53). Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 ++ scripts/build_site.py | 292 +++++++++++++++++++++++++++++++++------ scripts/render_docs.mjs | 81 ++++++++++- site/base.css | 84 +++++++++-- site/docs.css | 72 +++++++++- site/index.html | 15 +- site/site.js | 169 ++++++++++++++++++++++ site/theme.js | 11 ++ tests/test_build_site.py | 117 ++++++++++++++++ 9 files changed, 776 insertions(+), 73 deletions(-) create mode 100644 site/site.js create mode 100644 site/theme.js create mode 100644 tests/test_build_site.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3092521..35ea855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,14 @@ different event from one that moved because it was wrong. carries a canonical URL and Open Graph and Twitter card tags; the card image (`site/og.png`) is rendered from `scripts/og_image.html`. Missing paths get a 404 page that links back. +- The site has a header on every page (calculator, docs, example report, + GitHub), and the doc pages have a grouped docs sidebar, an "On this page" + contents list that follows the section in view, a link on every h2 and h3 + that copies itself, previous and next links, an "Edit on GitHub" link, and + copy buttons on code blocks. All of the navigation is HTML written at build + time, so it works with scripts off; `site/site.js` adds only the conveniences + and a light, dark, or automatic theme that is remembered between visits. + Form borders and the histogram's bars now meet 3:1 contrast in both themes. ### Changed diff --git a/scripts/build_site.py b/scripts/build_site.py index ba2564f..694fdc1 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -48,7 +48,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, TypedDict from plumbline.cli import app from plumbline.metrics.calibration import synthetic_floor @@ -70,52 +70,93 @@ class Doc: slug: str # docs/.html and docs/.md on the site label: str # its name in the navigation blurb: str # one line on the docs index + group: str # its heading in the docs sidebar, one of GROUPS -#: The docs the site hosts, in navigation order. A relative link between two of -#: these becomes a link between their pages; a link to any other file goes to -#: GitHub at the commit being built. +USING, PROJECT = "Using plumbline", "Project" + +#: The docs the site hosts, in navigation order: the sidebar lists them top to +#: bottom under their group, and previous and next follow the same order. A +#: relative link between two of these becomes a link between their pages; a +#: link to any other file goes to GitHub at the commit being built. DOCS = ( Doc( "README.md", "readme", "README", "What plumbline is, how to run it, and what it does not do.", + USING, ), Doc( "METHODOLOGY.md", "methodology", "Methodology", "How each figure is computed, and the floor each is reported against.", + USING, ), Doc( "docs/example-report.md", "example-report", "Example report", "One seeded mock run, rechecked against its command on every build.", + USING, + ), + Doc( + "datasets/public/README.md", + "dataset", + "Dataset", + "The vendored JevBench fixture: where it comes from and its license.", + USING, ), - Doc("docs/PLAN.md", "plan", "Plan", "What is built, what is deliberately not, and why."), - Doc("CHANGELOG.md", "changelog", "Changelog", "Notable changes per release."), + Doc( + "docs/PLAN.md", + "plan", + "Plan", + "What is built, what is deliberately not, and why.", + PROJECT, + ), + Doc("CHANGELOG.md", "changelog", "Changelog", "Notable changes per release.", PROJECT), Doc( "CONTRIBUTING.md", "contributing", "Contributing", "How to work on the code and what CI checks.", + PROJECT, ), Doc( "SECURITY.md", "security", "Security", "How to report a vulnerability, and what the scanners cover.", - ), - Doc( - "datasets/public/README.md", - "dataset", - "Dataset", - "The vendored JevBench fixture: where it comes from and its license.", + PROJECT, ), ) +#: The docs sidebar's groups, in order. +GROUPS = (USING, PROJECT) + + +class Heading(TypedDict): + level: int + id: str + text: str + + +class Section(TypedDict): + id: str | None # None for text before the first heading + heading: str + level: int + text: str + + +class Rendered(TypedDict): + """What ``scripts/render_docs.mjs`` returns for one doc.""" + + html: str + headings: list[Heading] + sections: list[Section] + + ECE_LINE = re.compile( r"^- (ECE (?P\d\.\d{4}) over (?P\d+) rows \((?P\d+) equal width bins\), " r"against a calibrated-model floor of (?P\d\.\d{4}) " @@ -306,7 +347,8 @@ def line(self, doc: Doc) -> str: return ( f'Rendered from {html.escape(doc.source)} ' f'at commit {short}{dirty}, built {at}. ' - f'Markdown source.' + f'Markdown source. ' + f'Edit on GitHub.' ) @@ -320,7 +362,7 @@ def provenance() -> Provenance: return Provenance(sha, built, modified) -def render_docs(prov: Provenance) -> dict[str, dict[str, str]]: +def render_docs(prov: Provenance) -> dict[str, Rendered]: """Markdown to HTML fragments, by the vendored renderer under the runner's Node.""" job = { "repo": REPO, @@ -347,18 +389,134 @@ def render_docs(prov: Provenance) -> dict[str, dict[str, str]]: raise BuildError(f"could not run node to render the docs: {missing}") from missing if done.returncode != 0: raise BuildError(f"the docs did not render:\n{done.stderr.strip()}") - rendered: dict[str, dict[str, str]] = json.loads(done.stdout) + rendered: dict[str, Rendered] = json.loads(done.stdout) return rendered -def _nav(current: str | None, up: str, docs: str = "") -> str: - """Links to the explainer (``up``) and every doc (``docs`` + slug).""" - items = [f'
  • The ECE floor
  • '] - for doc in DOCS: - mark = ' aria-current="page"' if doc.slug == current else "" - items.append(f'
  • {html.escape(doc.label)}
  • ') - joined = "\n ".join(items) - return f'' +# The plumb-bob mark, drawn inline so the header makes no request for it. +MARK = ( + '' +) + + +def site_header(root: str, current: str | None) -> str: + """The bar at the top of every page. + + ``root`` is the site's root relative to the page ("", "../", or the absolute + path on the 404 page). ``current`` is "calculator", "docs", or a doc slug, + and marks the matching link. The search and theme buttons are hidden until + ``site.js`` runs, so a reader without scripts never sees a dead control. + """ + links = ( + ("calculator", root, "Calculator"), + ("docs", f"{root}docs/", "Docs"), + ("example-report", f"{root}docs/example-report.html", "Example report"), + ) + items = [] + for key, href, label in links: + mark = ' aria-current="page"' if key == current else "" + items.append(f'
  • {label}
  • ') + items.append(f'
  • GitHub
  • ') + joined = "\n ".join(items) + return f"""""" + + +def docs_sidebar(current: str | None, prefix: str = "") -> str: + """Every hosted doc, by group, with the current one marked. + + ``prefix`` is the docs directory relative to the page: empty on a doc page, + absolute on the 404 page. + """ + groups = [] + for group in GROUPS: + items = [] + for doc in DOCS: + if doc.group != group: + continue + mark = ' aria-current="page"' if doc.slug == current else "" + items.append( + f'
  • {html.escape(doc.label)}
  • ' + ) + joined = "\n ".join(items) + groups.append( + f'

    {html.escape(group)}

    \n
      \n {joined}\n
    ' + ) + body = "\n ".join(groups) + return ( + '
    \n' + " Documentation\n" + f' \n
    " + ) + + +def page_toc(headings: list[Heading]) -> str: + """The page's h2 and h3 headings, h3 nested under its h2. + + Empty for a page with fewer than two h2s, where a contents list would only + repeat the page's one heading. + """ + groups: list[tuple[Heading, list[Heading]]] = [] + for heading in headings: + if heading["level"] == 2: + groups.append((heading, [])) + elif heading["level"] == 3 and groups: + groups[-1][1].append(heading) + if len(groups) < 2: + return "" + + def link(heading: Heading) -> str: + return f'{html.escape(heading["text"])}' + + items = [] + for h2, h3s in groups: + nested = "".join(f"
  • {link(h3)}
  • " for h3 in h3s) + items.append(f"
  • {link(h2)}" + (f"
      {nested}
    " if nested else "") + "
  • ") + return ( + '
    \n' + " On this page\n" + ' \n
    " + ) + + +def prev_next(slug: str) -> str: + """Links to the docs either side of this one, in sidebar order.""" + at = next(i for i, doc in enumerate(DOCS) if doc.slug == slug) + links = [] + if at > 0: + before = DOCS[at - 1] + links.append( + f'" + ) + if at < len(DOCS) - 1: + after = DOCS[at + 1] + links.append( + f'" + ) + return '" # The explainer's plumb-bob icon, inline so the page makes no request for it. @@ -419,71 +577,97 @@ def social_meta(title: str, description: str, url: str) -> str: {meta} + -
    -

    plumbline

    -{nav} -
    +{header} +
    +{sidebar}
    {body}
    +{toc} +

    Every page here is rendered from the repository at deploy time; none is edited by hand. Source on GitHub, Apache-2.0 licensed. No analytics, no trackers, no external requests.

    + """ -def _page(title: str, description: str, meta: str, root: str, nav: str, body: str) -> str: +def _page( + title: str, + description: str, + meta: str, + root: str, + *, + body: str, + doc: str | None = None, + header: str | None = "docs", + docs_prefix: str = "", + toc: str = "", +) -> str: + """One doc-shaped page: header, docs sidebar, the body, and its contents list.""" return PAGE.format( title=html.escape(title), description=html.escape(description, quote=True), meta=meta, icon=ICON, root=root, - nav=nav, + header=site_header(root, header), + sidebar=docs_sidebar(doc, docs_prefix), body=body, + toc=toc, repo=REPO, ) -def write_docs(out: Path, prov: Provenance, rendered: dict[str, dict[str, str]]) -> None: +def write_docs(out: Path, prov: Provenance, rendered: dict[str, Rendered]) -> None: docs = out / "docs" docs.mkdir() for doc in DOCS: shutil.copyfile(ROOT / doc.source, docs / f"{doc.slug}.md") body = ( f'

    {prov.line(doc)}

    \n' - f'
    \n{rendered[doc.slug]["html"]}
    ' + f'
    \n{rendered[doc.slug]["html"]}
    \n' + f"{prev_next(doc.slug)}" ) meta = social_meta(f"{doc.label} | plumbline", doc.blurb, f"{SITE_URL}docs/{doc.slug}.html") - page = _page(doc.label, doc.blurb, meta, "../", _nav(doc.slug, "../"), body) + toc = page_toc(rendered[doc.slug]["headings"]) + # The example report has its own link in the header; every other doc is "Docs". + header = "example-report" if doc.slug == "example-report" else "docs" + page = _page( + doc.label, doc.blurb, meta, "../", body=body, doc=doc.slug, header=header, toc=toc + ) (docs / f"{doc.slug}.html").write_text(page, encoding="utf-8") - listing = "\n".join( - f'
  • {html.escape(doc.label)} ' - f'{html.escape(doc.source)}
    ' - f"{html.escape(doc.blurb)}
  • " - for doc in DOCS - ) + listing = [] + for group in GROUPS: + items = "\n".join( + f'
  • {html.escape(doc.label)} ' + f'{html.escape(doc.source)}
    ' + f"{html.escape(doc.blurb)}
  • " + for doc in DOCS + if doc.group == group + ) + listing.append(f'

    {html.escape(group)}

    \n
      \n{items}\n
    ') at = prov.built.strftime("%Y-%m-%d %H:%M UTC") index = ( '
    \n

    Documentation

    \n' "

    The repository's own markdown, rendered from commit " f'{prov.sha[:7]} ' - f"at {at}.

    \n" - f'
      \n{listing}\n
    \n
    ' + f"at {at}.

    \n" + "\n".join(listing) + "\n" ) description = "plumbline's documentation, rendered from the repository." meta = social_meta("Documentation | plumbline", description, f"{SITE_URL}docs/") - page = _page("Documentation", description, meta, "../", _nav(None, "../"), index) + page = _page("Documentation", description, meta, "../", body=index) (docs / "index.html").write_text(page, encoding="utf-8") @@ -495,12 +679,30 @@ def write_404(out: Path) -> None: f'

    Go to the explainer, or ' f'browse the documentation.

    \n' ) - nav = _nav(None, SITE_PATH, f"{SITE_PATH}docs/") meta = '' - page = _page("Not found", "No page at this address.", meta, SITE_PATH, nav, body) + page = _page( + "Not found", + "No page at this address.", + meta, + SITE_PATH, + body=body, + header=None, + docs_prefix=f"{SITE_PATH}docs/", + ) (out / "404.html").write_text(page, encoding="utf-8") +#: Where the shared header goes in ``site/index.html``, which is written by hand. +HEADER_SLOT = "" + + +def explainer_page(source: str) -> str: + """The explainer as written, with the shared header in its slot.""" + if source.count(HEADER_SLOT) != 1: + raise BuildError(f"site/index.html must contain {HEADER_SLOT} exactly once") + return source.replace(HEADER_SLOT, site_header("", "calculator")) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--out", type=Path, default=ROOT / "_site", help="output directory") @@ -516,6 +718,7 @@ def main() -> int: example = build_example() prov = provenance() rendered = render_docs(prov) + explainer = explainer_page((SITE / "index.html").read_text(encoding="utf-8")) except BuildError as problem: print(f"site build refused: {problem}", file=sys.stderr) return 1 @@ -523,9 +726,10 @@ def main() -> int: if out.exists(): shutil.rmtree(out) # vendor/ holds the markdown renderer, which runs here at build time; the - # pages it produces need no script, so it is not shipped. + # pages it produces are finished HTML, so it is not shipped. shutil.copytree(SITE, out, ignore=shutil.ignore_patterns("vendor")) (out / "example-run.json").write_text(json.dumps(example, indent=1) + "\n", encoding="utf-8") + (out / "index.html").write_text(explainer, encoding="utf-8") write_docs(out, prov, rendered) write_404(out) print(f"site assembled in {out}") diff --git a/scripts/render_docs.mjs b/scripts/render_docs.mjs index 91c87a6..5440f5f 100644 --- a/scripts/render_docs.mjs +++ b/scripts/render_docs.mjs @@ -6,11 +6,15 @@ // // Input, on stdin, as JSON: // { repo, sha, tracked: [paths], docs: [{ source, slug, markdown }] } -// Output, on stdout, as JSON: { slug: { html } }. +// Output, on stdout, as JSON: { slug: { html, headings, sections } }, where +// headings is [{ level, id, text }] for the page's table of contents and +// sections is [{ id, heading, level, text }] (plain text split at h1 to h3) +// for the site's search index. // // Rendering happens here, at build time, not in the browser: the pages need no -// script at all, and every rule below fails the build instead of degrading on -// a reader's screen. The rules: +// script to be read and navigated (the site's one script only adds search, a +// theme toggle, and copy buttons), and every rule below fails the build +// instead of degrading on a reader's screen. The rules: // // - Raw HTML in markdown is refused unless it is one of a handful of literal // tags (ALLOWED_TAGS). There is no sanitizer to get wrong, only an allowlist @@ -105,6 +109,31 @@ function lineOf(token, doc) { return token.map ? `${doc.source}:${token.map[0] + 1}` : doc.source; } +// The plain text of a doc, split at each heading down to h3, for the site's +// search. Text before the first heading belongs to a section with no id. +function sectionsOf(tokens) { + const sections = []; + let current = { id: null, heading: "", level: 0, parts: [] }; + const flush = () => { + const text = current.parts.join(" ").replace(/\s+/g, " ").trim(); + if (current.id !== null || text) sections.push({ id: current.id, heading: current.heading, level: current.level, text }); + }; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.type === "heading_open" && Number(token.tag.slice(1)) <= 3) { + flush(); + current = { id: token.attrGet("id"), heading: inlineText(tokens[i + 1]), level: Number(token.tag.slice(1)), parts: [] }; + i += 1; // the heading's own inline token is its title, not its body + } else if (token.type === "inline") { + current.parts.push(inlineText(token)); + } else if (token.type === "fence" || token.type === "code_block") { + current.parts.push(token.content); + } + } + flush(); + return sections; +} + export function render(job) { const markdownit = loadMarkdownIt(); const md = markdownit({ html: true, linkify: false, typographer: false }); @@ -118,6 +147,19 @@ export function render(job) { const bySource = new Map(job.docs.map((doc) => [doc.source, doc])); const blob = (kind, target) => `https://github.com/${job.repo}/${kind}/${job.sha}/${target}`; + // A link on each h2 and h3, so a section can be linked to without reading the + // page source. It is emitted by the renderer, not written into the markdown, + // so the raw HTML allowlist is untouched. + md.renderer.rules.heading_close = (tokens, i, options, env, self) => { + const open = tokens[i - 2]; + const id = open?.type === "heading_open" ? open.attrGet("id") : null; + const anchor = + id && (open.tag === "h2" || open.tag === "h3") + ? ` #` + : ""; + return anchor + self.renderToken(tokens, i, options); + }; + // First pass: parse everything and collect heading anchors, so links can be // checked against the headings of the doc they point into. const parsed = new Map(); @@ -125,14 +167,16 @@ export function render(job) { const tokens = md.parse(doc.markdown, {}); const slug = slugger(); const anchors = new Set(); + const headings = []; tokens.forEach((token, i) => { if (token.type !== "heading_open") return; const text = inlineText(tokens[i + 1]); const id = slug(text); token.attrSet("id", id); anchors.add(id); + headings.push({ level: Number(token.tag.slice(1)), id, text }); }); - parsed.set(doc.source, { tokens, anchors }); + parsed.set(doc.source, { tokens, anchors, headings }); } const problems = []; @@ -166,9 +210,17 @@ export function render(job) { const out = {}; for (const doc of job.docs) { - const { tokens } = parsed.get(doc.source); + const { tokens, headings } = parsed.get(doc.source); for (const token of tokens) { const where = lineOf(token, doc); + // Table alignment arrives as an inline style; the site's CSP allows none. + if (token.type === "th_open" || token.type === "td_open") { + const align = /text-align:\s*(left|right|center)/.exec(token.attrGet("style") ?? ""); + if (align) { + token.attrs = token.attrs.filter(([name]) => name !== "style"); + token.attrJoin("class", `align-${align[1]}`); + } + } if (token.type === "html_block") checkHtml(token.content, where, problems); for (const child of token.children ?? []) { if (child.type === "html_inline") checkHtml(child.content, where, problems); @@ -188,7 +240,11 @@ export function render(job) { } } } - out[doc.slug] = { html: md.renderer.render(tokens, md.options, {}) }; + out[doc.slug] = { + html: md.renderer.render(tokens, md.options, {}), + headings, + sections: sectionsOf(tokens), + }; } if (problems.length) throw new RenderError(problems.join("\n")); @@ -234,7 +290,18 @@ function selfTest() { expect(!html("[![CI](https://e.x/b.svg)](https://e.x)\n").includes("\nPS\n\nx\n\n\n").includes("
    "), "allowlist"); - console.log("render_docs self-test: 15 cases pass"); + expect(html("# T\n\n## Two\n").includes('

    Two [h.level, h.id])) === '[[1,"top"],[2,"one"],[4,"deep"]]', + "headings", + ); + const sections = out.sections.map((s) => [s.id, s.text]); + expect(JSON.stringify(sections) === '[["top","lead x"],["one","body code Deep more"]]', `sections ${JSON.stringify(sections)}`); + console.log("render_docs self-test: 20 cases pass"); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/site/base.css b/site/base.css index c483fcf..026932e 100644 --- a/site/base.css +++ b/site/base.css @@ -1,12 +1,13 @@ -/* Shared by the explainer (index.html) and the doc pages (docs/*.html). */ +/* Shared by every page: the explainer (index.html), the docs (docs/*.html), and 404.html. */ :root { --bg: #fbfbf9; --fg: #1b1b1a; --muted: #5b5b56; --line: #d6d5cf; + --control: #85857f; /* borders of things you can press or type in: 3:1 on --bg and --panel */ --panel: #f1f0ec; --accent: #1f5f8b; - --bar: #9aa3ab; + --bar: #7b848c; --mark-measured: #1b1b1a; --mark-p95: #b3261e; --warn-bg: #fbf1dc; @@ -16,16 +17,20 @@ --error: #b3261e; --mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono", monospace; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --header-h: 3.25rem; + color-scheme: light; } +/* Dark: the system setting, unless the reader chose light; or the reader's choice. */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { --bg: #141413; --fg: #e7e6e1; --muted: #a3a29b; --line: #3a3a36; + --control: #6f6f69; --panel: #1e1e1c; --accent: #7db4dc; - --bar: #5d666e; + --bar: #6f7880; --mark-measured: #e7e6e1; --mark-p95: #f2b8b5; --warn-bg: #3a2c0e; @@ -33,29 +38,84 @@ --ok-bg: #173022; --ok-fg: #a9dcb8; --error: #f2b8b5; + color-scheme: dark; } } +:root[data-theme="dark"] { + --bg: #141413; + --fg: #e7e6e1; + --muted: #a3a29b; + --line: #3a3a36; + --control: #6f6f69; + --panel: #1e1e1c; + --accent: #7db4dc; + --bar: #6f7880; + --mark-measured: #e7e6e1; + --mark-p95: #f2b8b5; + --warn-bg: #3a2c0e; + --warn-fg: #f1d18f; + --ok-bg: #173022; + --ok-fg: #a9dcb8; + --error: #f2b8b5; + color-scheme: dark; +} + * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; } body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 var(--sans); } -main, header, footer { max-width: 46rem; margin: 0 auto; padding: 0 1rem; } -header { padding-top: 2rem; } -.kicker { font: 600 0.85rem var(--mono); color: var(--muted); margin: 0 0 0.5rem; letter-spacing: 0.02em; } -.kicker a { color: inherit; } +main, footer { max-width: 46rem; margin: 0 auto; padding: 0 1rem; } +.intro { padding-top: 2.5rem; } h1 { font-size: 1.65rem; line-height: 1.2; margin: 0 0 0.75rem; } h2 { font-size: 1.25rem; line-height: 1.3; margin: 0 0 0.75rem; } h3 { font-size: 1rem; margin: 1.5rem 0 0.5rem; } +h1, h2, h3, h4, section { scroll-margin-top: calc(var(--header-h) + 1rem); } p { margin: 0 0 1rem; } a { color: var(--accent); } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } code, .num { font-family: var(--mono); font-size: 0.92em; } .muted { color: var(--muted); } .small { font-size: 0.9rem; } -nav ul { list-style: none; padding: 0; margin: 1.25rem 0 2rem; display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; } -nav a { font-size: 0.95rem; } +.intro nav ul { list-style: none; padding: 0; margin: 1.25rem 0 2rem; display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; } +.intro nav a { font-size: 0.95rem; } nav a[aria-current="page"] { color: var(--fg); font-weight: 600; text-decoration: none; } blockquote { margin: 0 0 1rem; padding: 0.75rem 1rem; border-left: 3px solid var(--line); background: var(--panel); } blockquote p:last-child { margin: 0; } -footer { padding-bottom: 3rem; border-top: 1px solid var(--line); padding-top: 1.5rem; font-size: 0.9rem; color: var(--muted); } +footer { padding-bottom: 3rem; border-top: 1px solid var(--line); padding-top: 1.5rem; margin-top: 2rem; font-size: 0.9rem; color: var(--muted); } .skip { position: absolute; left: -999px; } -.skip:focus { left: 1rem; top: 1rem; background: var(--bg); padding: 0.5rem; z-index: 1; } +.skip:focus { left: 1rem; top: 1rem; background: var(--bg); padding: 0.5rem; z-index: 30; } +.offscreen { position: fixed; left: -9999px; top: 0; } + +/* ---- the header on every page ------------------------------------------ */ + +.site-header { position: sticky; top: 0; z-index: 20; background: var(--bg); border-bottom: 1px solid var(--line); } +.site-header .bar { max-width: 80rem; min-height: var(--header-h); margin: 0 auto; padding: 0.4rem 1rem; display: flex; align-items: center; flex-wrap: wrap; gap: 0.35rem 1.75rem; } +.brand { display: inline-flex; align-items: center; gap: 0.4rem; font: 700 1rem var(--mono); color: var(--fg); text-decoration: none; } +.brand .mark { color: var(--accent); } +.primary ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; } +.primary a { color: var(--muted); text-decoration: none; font-size: 0.95rem; } +.primary a:hover { color: var(--fg); text-decoration: underline; } +.primary a[aria-current="page"] { color: var(--fg); font-weight: 600; } +.tools { margin-left: auto; display: flex; gap: 0.5rem; } +.tools button { font: 0.85rem var(--sans); color: var(--fg); background: var(--panel); border: 1px solid var(--control); border-radius: 4px; padding: 0.3rem 0.65rem; cursor: pointer; white-space: nowrap; } +.tools button:hover { border-color: var(--fg); } +kbd { font: 0.75rem var(--mono); border: 1px solid var(--control); border-radius: 3px; padding: 0 0.3rem; color: var(--muted); } +/* Narrow screens: the links take their own row, and the header scrolls away + rather than holding two rows of the screen. */ +@media (max-width: 48rem) { + .site-header { position: static; } + h1, h2, h3, h4, section { scroll-margin-top: 1rem; } + .primary { order: 3; flex-basis: 100%; } + .tools kbd { display: none; } +} + +/* ---- a status line for copy and theme changes -------------------------- */ + +.toast { position: fixed; bottom: 1.25rem; left: 50%; transform: translateX(-50%); z-index: 40; background: var(--fg); color: var(--bg); padding: 0.4rem 0.85rem; border-radius: 4px; font-size: 0.9rem; opacity: 0; pointer-events: none; transition: opacity 0.15s; } +.toast.show { opacity: 1; } +@media (prefers-reduced-motion: reduce) { .toast { transition: none; } } + +@media print { + .site-header, .skip, .toast, footer { display: none !important; } + body { background: #fff; color: #000; } + a { color: inherit; } +} diff --git a/site/docs.css b/site/docs.css index 1862ab9..fe2590d 100644 --- a/site/docs.css +++ b/site/docs.css @@ -1,10 +1,49 @@ /* The doc pages: markdown rendered at build time by scripts/render_docs.mjs. */ + +/* ---- layout: sidebar, text, contents ------------------------------------ + Below 64rem everything stacks and the sidebar and contents are collapsible. + From 64rem the sidebar sits to the left; from 80rem the contents list sits + to the right. site.js opens and folds them to match (see its breakpoints). */ +.docs-layout { max-width: 80rem; margin: 0 auto; padding: 1.5rem 1rem 0; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "nav" "toc" "main"; } +.docs-layout main { grid-area: main; max-width: 46rem; width: 100%; margin: 0 auto; padding: 0; min-width: 0; } +.docs-nav { grid-area: nav; } +.toc { grid-area: toc; } +@media (min-width: 64rem) { + .docs-layout { grid-template-columns: 13rem minmax(0, 46rem); grid-template-areas: "nav toc" "nav main"; grid-template-rows: auto 1fr; justify-content: center; column-gap: 3rem; } + .docs-layout main { margin: 0; } + .docs-nav { position: sticky; top: calc(var(--header-h) + 1.5rem); align-self: start; max-height: calc(100vh - var(--header-h) - 3rem); overflow-y: auto; } + .docs-nav.collapsible { border: 0; background: none; padding: 0; margin: 0; } + .docs-nav > summary { display: none; } +} +@media (min-width: 80rem) { + .docs-layout { grid-template-columns: 13rem minmax(0, 46rem) 13rem; grid-template-areas: "nav main toc"; grid-template-rows: auto; } + .toc { position: sticky; top: calc(var(--header-h) + 1.5rem); align-self: start; max-height: calc(100vh - var(--header-h) - 3rem); overflow-y: auto; } + .toc.collapsible { border: 0; background: none; padding: 0; margin: 0; } + .toc > summary { display: none; } + .docs-layout .toc .label { display: block; } +} + +.collapsible { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 0.5rem 0.75rem; margin: 0 0 1rem; } +.collapsible > summary { cursor: pointer; font-weight: 600; font-size: 0.95rem; } +.collapsible[open] > summary { margin-bottom: 0.5rem; } +.side ul { list-style: none; margin: 0; padding: 0; } +.side p { margin: 0 0 0.5rem; } +.side a { display: block; padding: 0.2rem 0.6rem; border-left: 2px solid transparent; color: var(--muted); text-decoration: none; font-size: 0.9rem; line-height: 1.4; } +.side a:hover { color: var(--fg); text-decoration: underline; } +.side a[aria-current] { color: var(--fg); font-weight: 600; border-left-color: var(--accent); } +.side .group { font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 1.1rem 0 0.35rem 0.6rem; } +.toc ul ul a { padding-left: 1.4rem; font-size: 0.85rem; } +.toc-top { margin: 0.75rem 0 0; } +.toc-top a { font-size: 0.85rem; } +.toc .label { display: none; font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.5rem 0.6rem; } + +/* ---- the text ------------------------------------------------------------ */ + .provenance { font-size: 0.85rem; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.75rem; margin: 0 0 2rem; overflow-wrap: anywhere; } -.prose { padding-bottom: 2rem; overflow-wrap: break-word; } -.prose h1 { margin-top: 0; } +.prose { padding-bottom: 1rem; overflow-wrap: break-word; } +.prose h1 { margin-top: 0; font-size: 2rem; } .prose h2 { margin-top: 2.25rem; padding-top: 1rem; border-top: 1px solid var(--line); } .prose h3, .prose h4 { margin-top: 1.75rem; } -.prose h2, .prose h3, .prose h4 { scroll-margin-top: 1rem; } .prose ul, .prose ol { padding-left: 1.4rem; margin: 0 0 1rem; } .prose li { margin-bottom: 0.3rem; } .prose li > p { margin-bottom: 0.4rem; } @@ -15,9 +54,36 @@ .prose table { display: block; overflow-x: auto; border-collapse: collapse; margin: 0 0 1rem; font-size: 0.9rem; max-width: 100%; } .prose th, .prose td { padding: 0.35rem 0.6rem; border: 1px solid var(--line); text-align: left; vertical-align: top; } .prose th { background: var(--panel); font-weight: 600; } +.prose .align-right { text-align: right; } +.prose .align-center { text-align: center; } .prose details { border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.75rem; margin: 0 0 1rem; } .prose summary { cursor: pointer; } .prose details[open] > summary { margin-bottom: 0.5rem; } .doc-list { padding-left: 0; list-style: none; } .doc-list li { margin-bottom: 1rem; } .doc-list a { font-weight: 600; } + +/* A section's own link, shown on hover or focus. */ +.anchor { margin-left: 0.2rem; color: var(--muted); font-weight: 400; text-decoration: none; opacity: 0; } +h2:hover > .anchor, h3:hover > .anchor, .anchor:focus-visible { opacity: 1; } +@media (hover: none) { .anchor { opacity: 0.55; } } + +/* Code blocks get a copy button from site.js. */ +.code-block { position: relative; } +.code-block .copy { position: absolute; top: 0.4rem; right: 0.4rem; font: 0.75rem var(--sans); color: var(--fg); background: var(--bg); border: 1px solid var(--control); border-radius: 4px; padding: 0.15rem 0.5rem; cursor: pointer; opacity: 0; } +.code-block:hover .copy, .code-block .copy:focus-visible { opacity: 1; } +@media (hover: none) { .code-block .copy { opacity: 1; } } + +/* ---- previous and next --------------------------------------------------- */ + +.pager { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0 2rem; } +.pager a { flex: 1 1 14rem; border: 1px solid var(--line); border-radius: 6px; padding: 0.7rem 1rem; font-weight: 600; text-decoration: none; } +.pager a:hover { border-color: var(--accent); } +.pager span { display: block; font: 400 0.8rem var(--sans); color: var(--muted); } +.pager .next { text-align: right; } +.pager .next:only-child { margin-left: auto; flex-grow: 0; } + +@media print { + .side, .pager, .copy, .anchor { display: none !important; } + .docs-layout { display: block; max-width: none; padding: 0; } +} diff --git a/site/index.html b/site/index.html index 159f64b..4a92b1d 100644 --- a/site/index.html +++ b/site/index.html @@ -22,6 +22,7 @@ +