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 @@ +