code.pablomurad.com

mdssg

Single-file static site generator for a folder of linked markdown notes

feat: ship retro theme and global graph
author
Pablo Murad
date
commit
85d16d18b5771e26244df1ae5f91d61be7348b8d
parent
fe9c03c099abec7a3be17b2144d1694d73db7224
changes
18 files +838 −490
@@ -4,7 +4,7 @@ A static site generator for a folder of linked markdown notes. One file, no dependencies, Linux and Windows.
 
 ```sh
-python tools/build_pyz.py          # produces mdssg.pyz, about 60 KB
+python tools/build_pyz.py          # produces one small mdssg.pyz
 python mdssg.pyz init my-site
 python mdssg.pyz serve -C my-site
 ```
@@ -49,6 +49,10 @@ **Related notes come from the shape of the graph** — bibliographic coupling and co-citation over your own links. No tags to maintain, no model to run, and
 `mdssg doctor --explain-related X` will tell you why.
 
+**The graph is visible.** Every build includes a compact, clickable `/graph/`
+map of the written notes, seed pages and the links between them. It is an SVG,
+not a JavaScript application.
+
 ## Documentation
 
 **The documentation is the example site, and it is written with mdssg.**
@@ -87,14 +91,12 @@ ``` 
 ## What is in the box
 
-Its own markdown parser, its own template engine, a link graph, an incremental
-build, a dev server with live reload, RSS, canonical URLs, a sitemap, and a CGI
-handler for hosting that predates all of this. Roughly 3,900 lines of Python
-and an empty dependency list.
+Its own markdown parser, its own template engine, a visible link graph, an
+incremental build, a dev server with live reload, RSS, canonical URLs, a
+sitemap, and a CGI handler for hosting that predates all of this. The dependency
+list is empty.
 
-No plugin system, no shortcodes, no taxonomy engine, no asset pipeline. No
-graph visualisation, permanently — every garden generator has one and nobody
-navigates by it.
+No plugin system, no shortcodes, no taxonomy engine, no asset pipeline.
 
 ## Development
 
@@ -13,7 +13,7 @@ 2. **Graph** — index every name and alias, resolve every `[[link]]`, invent a    seed for every name nothing wrote, collect mention excerpts, compute
    backlinks and [[Related Notes]].
 3. **Render** — turn block trees into HTML, resolving wikilinks through the
-   graph and expanding embeds.
+   graph, expanding embeds, and lay out the global graph as SVG.
 4. **Emit** — write each output only if its bytes differ from last time, then
    delete anything the previous build produced and this one did not.
 
@@ -100,7 +100,6 @@ ## Deliberate omissions
 
 No plugin system. No shortcodes. No taxonomy engine. No asset pipeline. No
-[[Full Text Search]] — yet. No graph visualisation, permanently: every garden
-generator has one and nobody navigates by it.
+[[Full Text Search]] — yet.
 
 The dependency list is empty and the intent is to keep it that way.
@@ -31,6 +31,7 @@ page assembled from every sentence that mentioned it. - [[Backlinks]] quote the sentence they came from, not just the title.
 - [[Related Notes]] come from the shape of the graph, with no tags to maintain
   and no model to run.
+- The global `/graph/` view turns that structure into a compact, clickable SVG.
 
 ## Start here
 
@@ -44,12 +45,9 @@ ## What it does not do
 
 It has no plugin system, no taxonomy engine, no shortcodes, no asset pipeline,
-no [[Full Text Search]] yet, and no graph visualisation — that last one is a
-permanent non-goal rather than a missing feature. Every garden generator has
-one, and nobody navigates by it.
+and no [[Full Text Search]] yet.
 
-It is roughly 3,900 lines of Python with an empty dependency list, and the
-intent is to keep all three of those properties.
+It has an empty dependency list and the intent is to keep it that way.
 
 ## Status
 
@@ -15,9 +15,11 @@ base.html         the html shell   index.html        the home page
   page.html         a note
   seed.html         a seed page
+  graph.html        the global graph
   _backlinks.html   the "mentioned in" panel
   _related.html     the "related" panel
   style.css         the stylesheet
+  logo.svg          the site mark
 ```
 
 ## The language
@@ -80,11 +82,12 @@ |------|----------| | `site` | `title`, `url`, `description`, `author`, `language` |
 | `page` | `title`, `url`, `slug`, `date`, `tags`, `html`, `description`, `is_post`, `is_seed`, `noindex` |
 | `related` | list of `{title, url, seed}` |
-| `asset_url`, `feed_url` | paths to the stylesheet and feed |
+| `asset_url`, `logo_url`, `feed_url` | paths to the stylesheet, logo and feed |
 
 Notes also get `backlinks`, a list of `{title, url, excerpts}` where each
 excerpt is HTML with the mention wrapped in `<mark>`. Seed pages get `mentions`
-in the same shape. The home page gets `posts` and `notes`.
+in the same shape. The home page gets `posts` and `notes`. The graph page gets
+`graph`, containing the rendered SVG plus node, link and seed counts.
 
 ## Styling the graph
 
@@ -98,7 +101,11 @@ .backlinks mark      /* the mention inside a quote   */ .seed-notice         /* the banner on a seed page    */
 .embed               /* transcluded content          */
 .embed-error         /* a cycle or a depth limit     */
+.graph-map            /* the complete SVG graph       */
+.graph-node           /* a written note               */
+.graph-seed           /* an unwritten seed            */
 ```
 
-The bundled stylesheet is about 400 lines, uses system fonts, adapts to
-`prefers-color-scheme`, and ships no JavaScript at all.
+The bundled theme uses system fonts and ships no JavaScript at all.
+`/graph/` is reserved for the global graph; use another explicit slug for a
+note that would otherwise claim that URL.
@@ -165,7 +165,7 @@ try:             from . import render as render_module
 
             return (
-                self.index.get("version") != 2
+                self.index.get("version") != 3
                 or self.index.get("config_hash") != self.settings.digest
                 or self.index.get("sources") != render_module.source_state(self.settings)
             )
@@ -176,6 +176,11 @@ def handle(self, path: str) -> tuple[int, bytes]:         route = self.index.get("routes", {}).get(normalise(path))
         if route is None:
             return 404, self.render_missing(path).encode("utf-8")
+        if route.get("kind") == "graph":
+            from . import render as render_module
+
+            graph_path = self.settings.output_dir.joinpath(*render_module.GRAPH_PARTS)
+            return 200, graph_path.read_bytes()
         return 200, self.render(route).encode("utf-8")
 
     def context_for(self, slug: str) -> dict:
@@ -195,6 +200,7 @@ }         shared = {
             "site": site,
             "asset_url": render_module.ASSET_URL,
+            "logo_url": render_module.LOGO_URL,
             "feed_url": "",
             "related": data.get("related", []),
             "stale": self.stale(),
@@ -452,6 +452,90 @@ {"title": node.title, "url": node.url, "seed": node.is_seed}             for node in self.related.get(slug, [])
         ]
 
+    def visualisation(self) -> dict:
+        """A deterministic, dependency-free SVG view of the whole graph."""
+        ranked = sorted(
+            self.nodes.values(),
+            key=lambda node: (-self._degree(node.slug), node.slug),
+        )
+        count = len(ranked)
+        width, height = 960, 640
+        centre_x, centre_y = width / 2, height / 2
+        reach = height / 2 - 55
+        positions: dict[str, tuple[float, float]] = {}
+
+        for index, node in enumerate(ranked):
+            if index == 0:
+                positions[node.slug] = (centre_x, centre_y)
+                continue
+            angle = index * 2.399963229728653
+            radius = reach * math.sqrt(index / max(1, count - 1))
+            positions[node.slug] = (
+                centre_x + math.cos(angle) * radius * 1.45,
+                centre_y + math.sin(angle) * radius,
+            )
+
+        edges = [
+            (source, target)
+            for source in sorted(self.out_links)
+            for target in sorted(self.out_links[source])
+            if source in positions and target in positions
+        ]
+        maximum_degree = max((self._degree(node.slug) for node in ranked), default=1)
+        label_limit = min(count, max(24, int(math.sqrt(count) * 8)))
+
+        svg = [
+            '<svg class="graph-map" viewBox="0 0 960 640" role="img" '
+            'aria-labelledby="graph-title graph-description">',
+            '<title id="graph-title">Site graph</title>',
+            '<desc id="graph-description">Linked notes and seed pages. '
+            "Each node opens its page.</desc>",
+            '<g class="graph-edges" aria-hidden="true">',
+        ]
+        for source, target in edges:
+            x1, y1 = positions[source]
+            x2, y2 = positions[target]
+            svg.append(
+                f'<line x1="{x1:.1f}" y1="{y1:.1f}" '
+                f'x2="{x2:.1f}" y2="{y2:.1f}" />'
+            )
+        svg.append("</g>\n<g class=\"graph-nodes\">")
+
+        for index, node in enumerate(ranked):
+            x, y = positions[node.slug]
+            degree = self._degree(node.slug)
+            radius = 4.5 + 5 * math.sqrt(degree / max(1, maximum_degree))
+            kind = " graph-seed" if node.is_seed else ""
+            title = md_html.escape(node.title)
+            label = node.title if len(node.title) <= 24 else node.title[:23] + "…"
+            svg.append(
+                f'<a class="graph-node{kind}" href="{md_html.escape_attr(node.url)}">'
+                f"<title>{title}: {degree} connection"
+                f'{"s" if degree != 1 else ""}</title>'
+                f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{radius:.1f}" />'
+                + (
+                    f'<text x="{x + radius + 4:.1f}" y="{y + 3.5:.1f}">'
+                    f"{md_html.escape(label)}</text>"
+                    if index < label_limit
+                    else ""
+                )
+                + "</a>"
+            )
+
+        svg.append("</g>\n</svg>")
+        seed_count = sum(node.is_seed for node in ranked)
+        return {
+            "svg": Markup("\n".join(svg)),
+            "nodes": count,
+            "edges": len(edges),
+            "seeds": seed_count,
+            "summary": (
+                f"{count} node{'s' if count != 1 else ''}, "
+                f"{len(edges)} link{'s' if len(edges) != 1 else ''}, "
+                f"{seed_count} seed{'s' if seed_count != 1 else ''}."
+            ),
+        }
+
 
 def build(pages: list[Page], settings: Config) -> Graph:
     return Graph(pages, settings)
@@ -19,10 +19,15 @@ textutil,     transclude,
 )
 from .config import Config
+from .errors import BuildError
 from .model import Page, date_key
 
 ASSET_NAME = "style.css"
 ASSET_URL = "/assets/style.css"
+LOGO_NAME = "logo.svg"
+LOGO_URL = "/assets/logo.svg"
+GRAPH_URL = "/graph/"
+GRAPH_PARTS = ("graph", "index.html")
 
 
 @dataclass
@@ -40,16 +45,23 @@ path.parent.mkdir(parents=True, exist_ok=True)     path.write_text(text, encoding="utf-8", newline="\n")
 
 
-def stylesheet(settings: Config) -> str:
-    """The site's own stylesheet if it has one, otherwise the theme's."""
-    override = settings.templates_dir / ASSET_NAME
+def theme_asset(settings: Config, name: str) -> str:
+    override = settings.templates_dir / name
     if override.is_file():
         return override.read_text(encoding="utf-8-sig")
     return (
         importlib.resources.files(template.THEME_PACKAGE)
-        .joinpath(ASSET_NAME)
+        .joinpath(name)
         .read_text(encoding="utf-8")
     )
+
+
+def check_graph_route(site_graph: graph.Graph) -> None:
+    if any(node.url == GRAPH_URL for node in site_graph.nodes.values()):
+        raise BuildError(
+            f"{GRAPH_URL} is reserved for the global graph; "
+            "give that note or seed a different name or explicit slug"
+        )
 
 
 def emit_static(settings: Config, emitter: cache.Emitter) -> None:
@@ -126,6 +138,45 @@ )     return posts, notes
 
 
+def graph_page(
+    site_graph: graph.Graph,
+    settings: Config,
+    loader: template.Loader,
+    site: dict,
+    feed_url: str,
+) -> str:
+    page = {
+        "title": "Graph",
+        "name": "Graph",
+        "url": GRAPH_URL,
+        "slug": "graph",
+        "date": None,
+        "tags": [],
+        "html": template.Markup(""),
+        "description": "A map of the links between notes.",
+        "is_post": False,
+        "is_seed": False,
+        "canonical": settings.site.url + GRAPH_URL if settings.site.url else "",
+        "noindex": False,
+        "title_in_body": True,
+    }
+    return md_html.prefix_root_urls(
+        loader.render(
+            "graph.html",
+            {
+                "site": site,
+                "page": page,
+                "asset_url": ASSET_URL,
+                "logo_url": LOGO_URL,
+                "feed_url": feed_url,
+                "related": [],
+                "graph": site_graph.visualisation(),
+            },
+        ),
+        settings.base_path,
+    )
+
+
 def source_state(settings: Config) -> dict[str, list[int]]:
     """Cheap, exact-enough CGI staleness data without parsing every note."""
     state = {}
@@ -150,6 +201,10 @@ import json 
     pages = collect.collect(settings)
     site_graph = graph.build(pages, settings)
+    check_graph_route(site_graph)
+    loader = template.Loader(
+        settings.templates_dir if settings.templates_dir.is_dir() else None
+    )
 
     work = settings.work_dir
     context_dir = work / "ctx"
@@ -206,11 +261,13 @@ if stale.stem not in expected_contexts:             stale.unlink()
 
     posts, notes = listing(pages)
+    feed_url = feeds.FEED_URL if (settings.feed.enabled and settings.site.url) else ""
+    routes[GRAPH_URL] = {"kind": "graph"}
     write_text(
         work / "routes.json",
         json.dumps(
             {
-                "version": 2,
+                "version": 3,
                 "config_hash": settings.digest,
                 "sources": source_state(settings),
                 "routes": routes,
@@ -224,7 +281,15 @@ sort_keys=True,         ),
     )
 
-    write_text(settings.output_dir / "assets" / ASSET_NAME, stylesheet(settings))
+    for name in (ASSET_NAME, LOGO_NAME):
+        write_text(
+            settings.output_dir / "assets" / name,
+            theme_asset(settings, name),
+        )
+    write_text(
+        settings.output_dir.joinpath(*GRAPH_PARTS),
+        graph_page(site_graph, settings, loader, asdict(settings.site), feed_url),
+    )
     stats.written = len(routes)
     return stats
 
@@ -232,6 +297,7 @@ def build(settings: Config) -> Stats:
     pages = collect.collect(settings)
     site_graph = graph.build(pages, settings)
+    check_graph_route(site_graph)
     site_graph.report_near_misses()
     resolver = site_graph.resolver()
 
@@ -253,7 +319,8 @@ settings.output_dir.mkdir(parents=True, exist_ok=True)     emitter = cache.Emitter(settings.output_dir, manifest)
 
     emit_static(settings, emitter)
-    emitter.emit(("assets", ASSET_NAME), stylesheet(settings))
+    for name in (ASSET_NAME, LOGO_NAME):
+        emitter.emit(("assets", name), theme_asset(settings, name))
 
     posts, notes = listing(pages)
     site = asdict(settings.site)
@@ -273,6 +340,7 @@ context = {             "site": site,
             "page": page_context(page, rendered.html, settings),
             "asset_url": ASSET_URL,
+            "logo_url": LOGO_URL,
             "feed_url": feed_url,
             "backlinks": site_graph.backlinks.get(page.slug, []),
             "related": site_graph.related_for(page.slug),
@@ -297,6 +365,7 @@ context = {             "site": site,
             "page": seed_context(node, settings),
             "asset_url": ASSET_URL,
+            "logo_url": LOGO_URL,
             "feed_url": feed_url,
             "mentions": site_graph.seed_sources.get(node.slug, []),
             "backlinks": [],
@@ -309,6 +378,11 @@ )         if emitter.emit((node.slug, "index.html"), output):
             report.detail(f"(seed) -> {node.url}")
         stats.seeds += 1
+
+    emitter.emit(
+        GRAPH_PARTS,
+        graph_page(site_graph, settings, loader, site, feed_url),
+    )
 
     if settings.feed.enabled:
         feed = feeds.build(settings, pages, summaries)
@@ -19,6 +19,7 @@ settings.site.url + page.url         for page in pages
         if not page.meta.get("noindex")
     ]
+    urls.append(settings.site.url + "/graph/")
     if settings.seeds.in_sitemap and not settings.seeds.noindex:
         urls.extend(settings.site.url + node.url for node in seeds)
 
@@ -3,8 +3,8 @@ <html lang="{{ site.language | default:"en" }}"> <head>
 <meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
-<meta name="color-scheme" content="light dark" />
-<meta name="theme-color" content="#f4f1e8" />
+<meta name="color-scheme" content="light" />
+<meta name="theme-color" content="#ffffff" />
 <title>{% block title %}{{ page.title }} &middot; {{ site.title }}{% endblock %}</title>
 {% if page.description %}
 <meta name="description" content="{{ page.description }}" />
@@ -28,21 +28,33 @@ <div class="site-frame">
 <header class="site-header">
   <a class="site-title" href="/">
-    <span class="site-mark" aria-hidden="true"></span>
-    <span>{{ site.title }}</span>
+    <img src="{{ logo_url }}" alt="" width="36" height="36" />
+    <span class="site-name">{{ site.title }}</span>
+    <span class="site-kind">documentation</span>
   </a>
-  {% if site.description %}
-  <p class="site-tagline">{{ site.description }}</p>
-  {% endif %}
+</header>
+
+<aside class="site-sidebar">
+  <p class="sidebar-label">Navigation</p>
   <nav class="site-nav" aria-label="Site">
-    <a href="/">Index</a>
+    <a href="/">Overview</a>
+    <a href="/graph/">Graph</a>
     {% if feed_url %}
-    <a href="{{ feed_url }}">RSS</a>
+    <a href="{{ feed_url }}">RSS feed</a>
     {% endif %}
+    <a href="https://github.com/runawaydevil/mdssg">Source code</a>
   </nav>
-</header>
+
+  {% if site.description %}
+  <p class="sidebar-label sidebar-about-label">About</p>
+  <p class="site-tagline">{{ site.description }}</p>
+  {% endif %}
+</aside>
 
-<main id="content">
+<main class="site-main" id="content">
+<div class="content-path">
+  <a href="/">docs</a><span>/</span><strong>{{ page.title }}</strong>
+</div>
 {% if stale %}
 <aside class="stale-warning" role="status">
   This page changed after the CGI index was built. Rebuild the index to refresh links and graph data.
@@ -0,0 +1,16 @@+{% extends "base.html" %}
+{% block content %}
+<section class="graph-view">
+  <h1>Graph</h1>
+  <p class="graph-summary">
+    {{ graph.summary }} Select a node to open its page.
+  </p>
+  <div class="graph-canvas">
+    {{ graph.svg | raw }}
+  </div>
+  <p class="graph-legend">
+    <span><i class="graph-key graph-key-note"></i> written note</span>
+    <span><i class="graph-key graph-key-seed"></i> seed page</span>
+  </p>
+</section>
+{% endblock %}
@@ -0,0 +1,3 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="mdssg">
+  <path fill="#000080" d="M4 56V12h12v8h12v8h8v-8h12v-8h12v44H48V28H36v28H24V28h-8v28H4Z"/>
+</svg>
@@ -1,726 +1,830 @@-/* mdssg default theme.
-   Editorial, responsive and dependency-free: no webfonts, images or script. */
-
 :root {
-  color-scheme: light dark;
-  --measure: 43rem;
+  color-scheme: light;
+  --paper: #fff;
+  --panel: #f2f2f2;
+  --panel-dark: #e2e2e2;
+  --ink: #111;
+  --muted: #555;
+  --rule: #aaa;
+  --rule-dark: #222;
+  --blue: #0000ee;
+  --blue-dark: #000080;
+  --visited: #551a8b;
+  --hover: #8b0000;
+  --warning: #fff6bf;
+  --mono: "Courier New", Courier, monospace;
+  --sans: Arial, Helvetica, sans-serif;
   --shell: 72rem;
-  --ink: #17241f;
-  --ink-soft: #53615b;
-  --ink-faint: #626e68;
-  --paper: #f4f1e8;
-  --surface: #fbfaf6;
-  --surface-soft: #ece9df;
-  --rule: #d8d4c8;
-  --accent: #176b52;
-  --accent-strong: #0d513d;
-  --accent-soft: #dcebe4;
-  --seed: #9a641d;
-  --seed-soft: #f3e7cd;
-  --shadow: 0 1.5rem 4rem rgb(40 47 43 / 10%);
-  --radius: 1.5rem;
-  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
-  --text: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
-    sans-serif;
-  --display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino,
-    Georgia, serif;
 }
 
-@media (prefers-color-scheme: dark) {
-  :root {
-    --ink: #e8eee9;
-    --ink-soft: #aab8b1;
-    --ink-faint: #7e8c85;
-    --paper: #101713;
-    --surface: #17201b;
-    --surface-soft: #202a24;
-    --rule: #303c35;
-    --accent: #78d7b2;
-    --accent-strong: #a1e6ca;
-    --accent-soft: #18382c;
-    --seed: #e3b868;
-    --seed-soft: #392d18;
-    --shadow: 0 1.5rem 5rem rgb(0 0 0 / 28%);
-  }
-}
-
-*,
-*::before,
-*::after {
+* {
   box-sizing: border-box;
 }
 
 html {
-  -webkit-text-size-adjust: 100%;
-  scroll-behavior: smooth;
+  background: var(--panel-dark);
+  font-size: 16px;
+  text-size-adjust: 100%;
 }
 
 body {
-  min-height: 100vh;
   margin: 0;
-  background:
-    radial-gradient(circle at 8% 4%, var(--accent-soft), transparent 28rem),
-    var(--paper);
   color: var(--ink);
-  font-family: var(--text);
-  font-size: 1.0625rem;
-  line-height: 1.7;
-  text-rendering: optimizeLegibility;
+  background: var(--panel-dark);
+  font-family: var(--sans);
+  line-height: 1.55;
 }
 
 ::selection {
-  background: var(--accent);
-  color: var(--surface);
+  color: #fff;
+  background: var(--blue-dark);
+}
+
+a {
+  color: var(--blue);
+  text-decoration: underline;
+  text-decoration-thickness: 1px;
+  text-underline-offset: 2px;
+}
+
+a:visited {
+  color: var(--visited);
+}
+
+a:hover {
+  color: var(--hover);
+  background: #ffffcc;
+}
+
+a:focus-visible {
+  outline: 2px dotted var(--blue-dark);
+  outline-offset: 3px;
 }
 
 .skip {
   position: fixed;
-  top: 0.75rem;
-  left: 0.75rem;
-  z-index: 20;
-  padding: 0.55rem 0.8rem;
-  border-radius: 0.5rem;
-  background: var(--accent-strong);
-  color: var(--surface);
-  text-decoration: none;
-  transform: translateY(-150%);
+  z-index: 10;
+  top: 0.5rem;
+  left: 0.5rem;
+  padding: 0.45rem 0.65rem;
+  color: #fff;
+  background: var(--blue-dark);
+  border: 2px solid #000;
+  font: 700 0.8rem var(--mono);
+  transform: translateY(-200%);
 }
 
 .skip:focus {
-  transform: translateY(0);
+  color: #fff;
+  transform: none;
 }
 
 .site-frame {
   display: grid;
-  grid-template-columns: minmax(12rem, 17rem) minmax(0, var(--measure));
+  grid-template-columns: 14rem minmax(0, 1fr);
+  grid-template-rows: auto 1fr auto;
   grid-template-areas:
-    "header main"
+    "header header"
+    "sidebar main"
     "footer main";
-  align-items: start;
-  gap: 1.5rem clamp(2.5rem, 7vw, 7rem);
-  width: min(calc(100% - 3rem), var(--shell));
-  margin-inline: auto;
-  padding-block: clamp(2rem, 6vw, 5rem);
+  width: min(100%, var(--shell));
+  min-height: 100vh;
+  margin: 0 auto;
+  background: var(--paper);
+  border-right: 1px solid var(--rule);
+  border-left: 1px solid var(--rule);
 }
 
 .site-header {
   grid-area: header;
-  position: sticky;
-  top: 2rem;
-  padding-top: 0.35rem;
+  display: flex;
+  align-items: center;
+  min-height: 4.75rem;
+  padding: 0.75rem 1rem;
+  background: var(--paper);
+  border-top: 6px solid var(--blue-dark);
+  border-bottom: 2px solid var(--rule-dark);
 }
 
 .site-title {
   display: inline-flex;
-  align-items: center;
   gap: 0.7rem;
-  color: var(--ink);
-  font-size: 1rem;
-  font-weight: 750;
-  letter-spacing: -0.015em;
+  align-items: center;
+  color: var(--blue-dark);
   text-decoration: none;
 }
 
-.site-mark {
-  display: grid;
-  width: 2rem;
-  height: 2rem;
-  place-items: center;
-  border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--rule));
-  border-radius: 0.65rem;
-  background: var(--accent-soft);
-  color: var(--accent);
-  transform: rotate(-4deg);
-  transition: transform 180ms ease, background 180ms ease;
+.site-title:visited,
+.site-title:hover {
+  color: var(--blue-dark);
+}
+
+.site-title:hover {
+  background: transparent;
+}
+
+.site-title img {
+  display: block;
+  width: 2.25rem;
+  height: 2.25rem;
 }
 
-.site-mark::before {
-  content: "";
-  width: 0.65rem;
-  height: 0.65rem;
-  border: 2px solid currentColor;
-  border-radius: 50% 50% 50% 0;
-  transform: rotate(-45deg);
+.site-name {
+  font: 700 1.35rem/1 var(--mono);
 }
 
-.site-title:hover .site-mark {
-  background: var(--surface);
-  transform: rotate(5deg);
+.site-kind {
+  margin-left: 0.2rem;
+  color: var(--muted);
+  font: 400 0.7rem/1 var(--mono);
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
 }
 
-.site-tagline {
-  max-width: 15rem;
-  margin: 1rem 0 0;
-  color: var(--ink-soft);
-  font-size: 0.9rem;
-  line-height: 1.55;
+.site-sidebar {
+  grid-area: sidebar;
+  padding: 1.35rem 1rem 1rem;
+  background: var(--panel);
+  border-right: 1px solid var(--rule);
 }
 
-.site-nav {
-  display: flex;
-  gap: 1rem;
-  margin-top: 1.4rem;
-  font-family: var(--mono);
-  font-size: 0.72rem;
+.sidebar-label {
+  margin: 0 0 0.45rem;
+  color: var(--muted);
+  font: 700 0.7rem/1.2 var(--mono);
   letter-spacing: 0.08em;
   text-transform: uppercase;
 }
 
+.site-nav {
+  display: grid;
+  margin: 0 0 1.75rem;
+  border-top: 1px solid var(--rule);
+}
+
 .site-nav a {
-  color: var(--ink-faint);
-  text-decoration: none;
+  display: block;
+  padding: 0.45rem 0.35rem;
+  color: var(--blue-dark);
+  border-bottom: 1px solid var(--rule);
+  font: 700 0.82rem/1.3 var(--mono);
+}
+
+.site-nav a:visited {
+  color: var(--blue-dark);
 }
 
 .site-nav a:hover {
-  color: var(--accent);
+  color: #fff;
+  background: var(--blue-dark);
+}
+
+.site-tagline {
+  margin: 0;
+  color: #333;
+  font-size: 0.82rem;
+  line-height: 1.45;
 }
 
-main {
+.site-main {
   grid-area: main;
   min-width: 0;
-  padding: clamp(1.6rem, 4vw, 3.5rem);
-  border: 1px solid color-mix(in srgb, var(--rule) 80%, transparent);
-  border-radius: var(--radius);
-  background: color-mix(in srgb, var(--surface) 94%, transparent);
-  box-shadow: var(--shadow);
+  padding: 1.8rem clamp(1.25rem, 5vw, 4rem) 4rem;
+  background: var(--paper);
+}
+
+.content-path {
+  display: flex;
+  gap: 0.45rem;
+  align-items: baseline;
+  margin: 0 0 2rem;
+  padding: 0.42rem 0.6rem;
+  overflow: hidden;
+  color: var(--muted);
+  background: var(--panel);
+  border: 1px solid var(--rule);
+  font: 0.75rem/1.3 var(--mono);
+  white-space: nowrap;
+}
+
+.content-path strong {
+  overflow: hidden;
+  color: var(--ink);
+  font-weight: 400;
+  text-overflow: ellipsis;
 }
 
 .site-footer {
   grid-area: footer;
-  align-self: end;
-  padding-bottom: 0.5rem;
-  color: var(--ink-faint);
-  font-size: 0.78rem;
-  line-height: 1.5;
+  align-self: stretch;
+  padding: 1rem;
+  color: var(--muted);
+  background: var(--panel);
+  border-top: 1px solid var(--rule);
+  border-right: 1px solid var(--rule);
+  font: 0.7rem/1.5 var(--mono);
 }
 
 .site-footer p {
-  margin: 0.2rem 0;
+  margin: 0;
 }
 
 .site-footer a {
-  color: inherit;
+  color: var(--blue-dark);
 }
 
-/* --- prose ------------------------------------------------------------- */
-
-.note-title,
-.note > h1:first-child {
-  max-width: 14ch;
-  margin: 0 0 1.5rem;
-  font-family: var(--display);
-  font-size: clamp(2.5rem, 8vw, 4.6rem);
-  font-weight: 600;
-  letter-spacing: -0.045em;
-  line-height: 0.98;
-  text-wrap: balance;
+.note,
+.listing,
+.seed,
+.stale-warning {
+  width: 100%;
+  max-width: 52rem;
 }
 
-.note-meta,
-.note-tags {
-  margin: -0.65rem 0 1.5rem;
-  color: var(--ink-faint);
-  font-family: var(--mono);
-  font-size: 0.76rem;
-  letter-spacing: 0.04em;
-}
-
-.note-tags {
-  margin-top: -1.1rem;
-}
-
-.note h2,
-.note h3,
-.note h4,
-.note h5,
-.note h6 {
-  color: var(--ink);
+h1,
+h2,
+h3,
+h4 {
+  color: #000;
+  font-family: var(--sans);
   line-height: 1.2;
-  text-wrap: balance;
 }
 
-.note h2 {
-  margin: 3.2rem 0 1rem;
-  font-family: var(--display);
-  font-size: clamp(1.65rem, 4vw, 2.15rem);
-  font-weight: 600;
-  letter-spacing: -0.025em;
+h1 {
+  margin: 0 0 1.4rem;
+  padding-bottom: 0.45rem;
+  border-bottom: 2px solid #000;
+  font-size: clamp(1.8rem, 5vw, 2.3rem);
 }
 
-.note h3 {
-  margin: 2.4rem 0 0.8rem;
-  font-size: 1.16rem;
+h2 {
+  margin: 2.3rem 0 0.8rem;
+  padding-bottom: 0.28rem;
+  border-bottom: 1px solid var(--rule);
+  font-size: 1.4rem;
 }
 
-.note h4,
-.note h5,
-.note h6 {
-  margin: 2rem 0 0.7rem;
-  font-size: 0.92rem;
-  letter-spacing: 0.06em;
-  text-transform: uppercase;
+h3 {
+  margin: 1.8rem 0 0.65rem;
+  font-size: 1.12rem;
 }
 
-.note p,
-.note ul,
-.note ol,
-.note blockquote,
-.note pre,
-.note table {
-  margin: 0 0 1.35rem;
+h4 {
+  margin: 1.5rem 0 0.5rem;
+  font: 700 0.95rem/1.3 var(--mono);
 }
 
-.note > p:first-of-type {
-  color: var(--ink-soft);
-  font-size: 1.15rem;
-  line-height: 1.65;
+.heading-anchor {
+  margin-left: 0.3rem;
+  color: var(--blue);
+  font: 700 0.62em/1 var(--mono);
+  text-decoration: none;
+  vertical-align: 0.08em;
 }
 
-.note li {
-  margin-bottom: 0.42rem;
-  padding-left: 0.18rem;
+.heading-anchor:visited {
+  color: var(--blue);
 }
 
-.note li::marker {
-  color: var(--accent);
+p,
+ul,
+ol,
+dl,
+blockquote,
+pre,
+table {
+  margin-top: 0;
+  margin-bottom: 1rem;
 }
 
-.note li > ul,
-.note li > ol {
-  margin: 0.45rem 0 0;
+.note > p:first-of-type {
+  color: var(--ink);
+  font-size: 1rem;
 }
 
-a {
-  color: var(--accent-strong);
-  text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent);
-  text-decoration-thickness: 1px;
-  text-underline-offset: 0.2em;
-  transition: color 150ms ease, text-decoration-color 150ms ease;
+ul,
+ol {
+  padding-left: 1.55rem;
 }
 
-a:hover {
-  color: var(--accent);
-  text-decoration-color: currentColor;
+li + li {
+  margin-top: 0.22rem;
 }
 
-a:focus-visible {
-  border-radius: 0.15rem;
-  outline: 3px solid color-mix(in srgb, var(--accent) 32%, transparent);
-  outline-offset: 3px;
+dt {
+  font-weight: 700;
 }
 
-.heading-anchor {
-  margin-left: 0.45rem;
-  color: var(--ink-faint);
-  font-family: var(--text);
-  font-size: 0.65em;
-  font-weight: 500;
-  text-decoration: none;
-  opacity: 0;
+dd {
+  margin: 0 0 0.8rem 1.25rem;
 }
 
-h1:hover .heading-anchor,
-h2:hover .heading-anchor,
-h3:hover .heading-anchor,
-h4:hover .heading-anchor,
-.heading-anchor:focus {
-  opacity: 1;
+hr {
+  height: 0;
+  margin: 2rem 0;
+  border: 0;
+  border-top: 2px solid var(--rule-dark);
 }
 
-blockquote {
-  position: relative;
-  padding: 0.85rem 1rem 0.85rem 1.25rem;
-  border-left: 3px solid var(--accent);
-  border-radius: 0 0.8rem 0.8rem 0;
-  background: var(--accent-soft);
-  color: var(--ink-soft);
+img,
+svg {
+  max-width: 100%;
 }
 
-blockquote > :last-child {
-  margin-bottom: 0;
+.note > img,
+.note p > img,
+figure img {
+  display: block;
+  height: auto;
+  border: 1px solid var(--rule-dark);
 }
 
-hr {
-  width: 4rem;
-  height: 0;
-  margin: 3.5rem 0;
-  border: 0;
-  border-top: 3px solid var(--accent);
+figure {
+  margin: 1.5rem 0;
 }
 
-img {
-  display: block;
-  max-width: 100%;
-  height: auto;
-  border-radius: 0.9rem;
+figcaption {
+  margin-top: 0.35rem;
+  color: var(--muted);
+  font: 0.75rem/1.4 var(--mono);
 }
 
-code {
-  padding: 0.14em 0.36em;
-  border: 1px solid color-mix(in srgb, var(--rule) 75%, transparent);
-  border-radius: 0.35rem;
-  background: var(--surface-soft);
+code,
+kbd,
+samp {
   font-family: var(--mono);
-  font-size: 0.84em;
+  font-size: 0.88em;
+}
+
+:not(pre) > code {
+  padding: 0.08rem 0.25rem;
+  background: var(--panel);
+  border: 1px solid #ccc;
 }
 
 pre {
-  padding: 1.15rem 1.25rem;
+  max-width: 100%;
+  padding: 0.85rem 1rem;
   overflow-x: auto;
-  border: 1px solid var(--rule);
-  border-radius: 0.85rem;
-  background: var(--surface-soft);
-  font-size: 0.82rem;
-  line-height: 1.6;
-  scrollbar-color: var(--ink-faint) transparent;
+  color: #000;
+  background: #f7f7f7;
+  border: 1px solid var(--rule-dark);
+  border-left: 5px solid var(--blue-dark);
+  line-height: 1.5;
+  tab-size: 2;
 }
 
 pre code {
   padding: 0;
+  background: transparent;
   border: 0;
-  background: none;
-  font-size: inherit;
+}
+
+kbd {
+  padding: 0.08rem 0.3rem;
+  background: var(--panel);
+  border: 1px solid var(--rule-dark);
+  border-bottom-width: 2px;
+}
+
+blockquote {
+  margin-right: 0;
+  margin-left: 0;
+  padding: 0.75rem 1rem;
+  background: var(--panel);
+  border-left: 5px solid var(--blue-dark);
+}
+
+blockquote > :last-child {
+  margin-bottom: 0;
 }
 
 table {
-  width: 100%;
+  display: block;
+  width: max-content;
+  max-width: 100%;
+  overflow-x: auto;
   border-collapse: collapse;
   font-size: 0.9rem;
 }
 
 th,
 td {
-  padding: 0.65rem 0.75rem;
-  border-bottom: 1px solid var(--rule);
+  padding: 0.45rem 0.6rem;
+  border: 1px solid var(--rule);
   text-align: left;
+  vertical-align: top;
 }
 
 th {
-  background: var(--surface-soft);
-  color: var(--ink-soft);
-  font-size: 0.78rem;
-  font-weight: 700;
-  letter-spacing: 0.04em;
-  text-transform: uppercase;
+  color: #000;
+  background: var(--panel-dark);
 }
 
-.note table {
-  display: block;
-  overflow-x: auto;
-  border: 1px solid var(--rule);
-  border-radius: 0.75rem;
+tbody tr:nth-child(even) {
+  background: #fafafa;
 }
 
-.task-list {
-  padding-left: 0;
-  list-style: none;
+input[type="checkbox"] {
+  margin: 0 0.35rem 0 0;
+  accent-color: var(--blue-dark);
 }
 
-.task-list input {
-  width: 1rem;
-  height: 1rem;
-  margin-right: 0.45rem;
-  accent-color: var(--accent);
+.task-list-item {
+  list-style: none;
 }
 
-/* --- graph and listings ------------------------------------------------ */
+.task-list-item input {
+  margin-left: -1.4rem;
+}
 
 .wikilink-seed {
-  color: var(--seed);
-  text-decoration-line: underline;
+  color: var(--hover);
   text-decoration-style: dashed;
 }
 
+.wikilink-seed:visited {
+  color: var(--hover);
+}
+
 .backlinks,
 .related,
 .listing {
-  margin-top: 3.5rem;
-  padding-top: 1.5rem;
-  border-top: 1px solid var(--rule);
+  max-width: 52rem;
+  margin-top: 3rem;
+  padding-top: 1rem;
+  border-top: 2px solid var(--rule-dark);
 }
 
 .backlinks h2,
 .related h2,
 .listing h2 {
-  margin: 0 0 1.1rem;
-  color: var(--ink-faint);
-  font-family: var(--mono);
-  font-size: 0.7rem;
-  font-weight: 700;
-  letter-spacing: 0.12em;
+  margin: 0 0 1rem;
+  padding: 0.4rem 0.55rem;
+  color: #fff;
+  background: var(--blue-dark);
+  border: 0;
+  font: 700 0.85rem/1.2 var(--mono);
+  letter-spacing: 0.05em;
   text-transform: uppercase;
 }
 
 .backlinks ul,
 .related ul,
-.listing ul {
+.note-list,
+.post-list {
   margin: 0;
   padding: 0;
   list-style: none;
 }
 
-.backlinks > ul > li {
-  margin-bottom: 0.8rem;
-  padding: 1rem;
+.backlinks ul {
+  display: grid;
+  gap: 0.5rem;
+}
+
+.backlinks li {
+  margin: 0;
+  padding: 0.6rem 0.7rem;
+  background: #fafafa;
   border: 1px solid var(--rule);
-  border-radius: 0.8rem;
-  background: var(--surface-soft);
 }
 
-.backlinks blockquote,
-.seed-mentions blockquote {
+.backlinks blockquote {
   margin: 0.5rem 0 0;
-  padding: 0;
-  border: 0;
-  background: none;
   font-size: 0.88rem;
 }
 
-.backlinks mark,
-.seed-mentions mark {
-  padding: 0.08em 0.2em;
-  border-radius: 0.2rem;
-  background: var(--accent-soft);
-  color: inherit;
-  font-weight: 700;
-}
-
 .related ul,
 .note-list {
   display: grid;
   grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 0.6rem;
+  gap: 0.5rem;
 }
 
-.related li a,
-.note-list li a {
+.related li,
+.note-list li {
+  margin: 0;
+}
+
+.related a,
+.note-list a {
   display: block;
   height: 100%;
-  padding: 0.7rem 0.8rem;
+  padding: 0.65rem 0.75rem;
+  background: #fafafa;
   border: 1px solid var(--rule);
-  border-radius: 0.65rem;
-  background: var(--surface-soft);
-  text-decoration: none;
+  font-weight: 700;
+}
+
+.related a:hover,
+.note-list a:hover {
+  color: #fff;
+  background: var(--blue-dark);
+}
+
+.post-list {
+  border-top: 1px solid var(--rule);
 }
 
 .post-list li {
   display: grid;
-  grid-template-columns: 6.3rem minmax(0, 1fr);
+  grid-template-columns: 6.5rem minmax(0, 1fr);
   gap: 0.75rem;
-  padding: 0.65rem 0;
+  align-items: baseline;
+  margin: 0;
+  padding: 0.65rem 0.35rem;
   border-bottom: 1px solid var(--rule);
 }
 
-.post-list time {
-  color: var(--ink-faint);
-  font-family: var(--mono);
-  font-size: 0.75rem;
-  font-variant-numeric: tabular-nums;
+.post-list a {
+  font-weight: 700;
+}
+
+.post-list time,
+.note-meta,
+.note-tags {
+  color: var(--muted);
+  font: 0.75rem/1.4 var(--mono);
+}
+
+.note-meta,
+.note-tags {
+  margin: -0.7rem 0 1.5rem;
 }
 
-/* --- seeds, embeds and footnotes -------------------------------------- */
+.note-tags {
+  padding: 0.3rem 0.45rem;
+  background: var(--panel);
+  border-left: 3px solid var(--rule);
+}
 
-.seed-notice,
-.embed-error,
+.seed,
 .stale-warning {
-  margin: 0 0 2rem;
-  padding: 0.9rem 1rem;
-  border: 1px solid color-mix(in srgb, var(--seed) 40%, var(--rule));
-  border-radius: 0.75rem;
-  background: var(--seed-soft);
-  color: var(--ink-soft);
-  font-size: 0.9rem;
+  padding: 1rem;
+  background: var(--warning);
+  border: 2px solid var(--rule-dark);
 }
 
 .stale-warning {
-  margin-bottom: 2rem;
-  border-color: color-mix(in srgb, var(--seed) 55%, var(--rule));
-  background: var(--seed-soft);
-  color: var(--ink);
+  margin-bottom: 1.5rem;
+  font-weight: 700;
+}
+
+.seed-notice {
+  font-weight: 700;
 }
 
 .seed-mentions {
-  margin: 0;
-  padding-left: 1.25rem;
+  padding-left: 1.6rem;
 }
 
-.seed-mentions > li {
-  margin-bottom: 1.4rem;
+.seed-mentions > li + li {
+  margin-top: 1rem;
 }
 
 .embed {
-  margin: 1.8rem 0;
-  padding: 1.25rem 1.35rem 1rem;
+  margin: 1.25rem 0;
+  padding: 1rem;
+  background: #fafafa;
   border: 1px solid var(--rule);
-  border-left: 4px solid var(--accent);
-  border-radius: 0.8rem;
-  background: var(--surface-soft);
+}
+
+.embed > :first-child {
+  margin-top: 0;
 }
 
-.embed figcaption {
-  margin-top: 1rem;
-  color: var(--ink-faint);
-  font-family: var(--mono);
-  font-size: 0.7rem;
-  letter-spacing: 0.04em;
-  text-transform: uppercase;
+.embed > :last-child {
+  margin-bottom: 0;
 }
 
-.footnote-ref a {
-  padding: 0 0.12em;
-  text-decoration: none;
+mark {
+  color: #000;
+  background: #ffff66;
 }
 
 .footnotes {
-  margin-top: 3.5rem;
-  padding-top: 1.5rem;
+  margin-top: 2.5rem;
+  padding-top: 0.8rem;
   border-top: 1px solid var(--rule);
-  color: var(--ink-soft);
-  font-size: 0.86rem;
+  font-size: 0.88rem;
+}
+
+.graph-view {
+  width: 100%;
+  max-width: 54rem;
+}
+
+.graph-summary {
+  margin: -0.6rem 0 1rem;
+  color: var(--muted);
+  font: 0.8rem/1.4 var(--mono);
+}
+
+.graph-canvas {
+  width: 100%;
+  overflow: auto;
+  background: #fff;
+  border: 2px solid var(--rule-dark);
+}
+
+.graph-map {
+  display: block;
+  width: 100%;
+  min-width: 42rem;
+  height: auto;
+}
+
+.graph-edges line {
+  stroke: #aaa;
+  stroke-width: 1.2;
+  vector-effect: non-scaling-stroke;
+}
+
+.graph-node circle {
+  fill: var(--blue-dark);
+  stroke: #fff;
+  stroke-width: 1.5;
+  vector-effect: non-scaling-stroke;
+}
+
+.graph-node text {
+  fill: #111;
+  font: 700 12px var(--mono);
+  paint-order: stroke;
+  pointer-events: none;
+  stroke: #fff;
+  stroke-width: 3px;
+  stroke-linejoin: bevel;
+}
+
+.graph-node:hover {
+  background: transparent;
+}
+
+.graph-node:hover circle,
+.graph-node:focus circle {
+  fill: var(--hover);
+  stroke: #000;
+  stroke-width: 2.5;
 }
 
-.footnotes ol {
-  padding-left: 1.25rem;
+.graph-seed circle {
+  fill: #fff;
+  stroke: var(--hover);
+  stroke-dasharray: 3 2;
+  stroke-width: 2;
 }
 
-.footnote-back {
-  margin-left: 0.25rem;
-  font-family: var(--mono);
-  font-size: 0.72em;
-  text-decoration: none;
+.graph-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.75rem 1.25rem;
+  margin: 0;
+  padding: 0.55rem 0.7rem;
+  background: var(--panel);
+  border: 1px solid var(--rule);
+  border-top: 0;
+  font: 0.75rem/1.3 var(--mono);
 }
 
-@media (max-width: 52rem) {
+.graph-legend span {
+  display: inline-flex;
+  gap: 0.4rem;
+  align-items: center;
+}
+
+.graph-key {
+  display: inline-block;
+  width: 0.7rem;
+  height: 0.7rem;
+  background: var(--blue-dark);
+  border: 1px solid var(--blue-dark);
+}
+
+.graph-key-seed {
+  background: #fff;
+  border-color: var(--hover);
+  border-style: dashed;
+}
+
+@media (max-width: 46rem) {
+  html,
+  body {
+    background: var(--paper);
+  }
+
   .site-frame {
     grid-template-columns: 1fr;
+    grid-template-rows: auto auto 1fr auto;
     grid-template-areas:
       "header"
+      "sidebar"
       "main"
       "footer";
-    width: min(calc(100% - 2rem), var(--measure));
-    padding-block: 1.25rem 2.5rem;
+    border: 0;
   }
 
   .site-header {
-    position: static;
-    display: grid;
-    grid-template-columns: 1fr auto;
-    align-items: center;
-    gap: 0.25rem 1rem;
-    padding: 0.4rem 0.25rem 0.8rem;
+    min-height: 4rem;
+    padding: 0.65rem 1rem;
   }
 
+  .site-kind {
+    display: none;
+  }
+
+  .site-sidebar {
+    padding: 0.75rem 1rem;
+    border-right: 0;
+    border-bottom: 1px solid var(--rule);
+  }
+
+  .site-sidebar .sidebar-label,
   .site-tagline {
     display: none;
   }
 
   .site-nav {
-    grid-column: 2;
-    grid-row: 1;
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.35rem 1rem;
     margin: 0;
+    border: 0;
   }
 
-  .site-footer {
-    padding-inline: 0.25rem;
+  .site-nav a {
+    padding: 0.2rem 0;
+    border: 0;
   }
-}
+
+  .site-main {
+    padding: 1.25rem 1rem 3rem;
+  }
 
-@media (max-width: 34rem) {
-  body {
-    font-size: 1rem;
-    background: var(--paper);
+  .content-path {
+    margin-bottom: 1.5rem;
   }
 
-  .site-frame {
-    width: 100%;
-    gap: 0.5rem;
-    padding-top: 0.6rem;
+  .graph-map {
+    width: 42rem;
   }
 
-  .site-header,
   .site-footer {
-    padding-inline: 1rem;
+    padding: 0.8rem 1rem;
+    border-right: 0;
   }
+}
 
-  .site-mark {
-    width: 1.75rem;
-    height: 1.75rem;
+@media (max-width: 30rem) {
+  .site-title img {
+    width: 2rem;
+    height: 2rem;
   }
 
-  main {
-    padding: 1.6rem 1.1rem 2.2rem;
-    border-inline: 0;
-    border-radius: 0;
-    box-shadow: none;
+  .site-name {
+    font-size: 1.15rem;
   }
 
-  .note-title,
-  .note > h1:first-child {
-    font-size: clamp(2.4rem, 15vw, 3.7rem);
+  h1 {
+    font-size: 1.75rem;
   }
 
   .related ul,
-  .note-list {
-    grid-template-columns: 1fr;
-  }
-
+  .note-list,
   .post-list li {
     grid-template-columns: 1fr;
-    gap: 0.15rem;
   }
-}
 
-@media (prefers-reduced-motion: no-preference) {
-  main {
-    animation: arrive 320ms ease-out both;
-  }
-
-  @keyframes arrive {
-    from {
-      opacity: 0;
-      transform: translateY(0.45rem);
-    }
-  }
-}
-
-@media (prefers-reduced-motion: reduce) {
-  html {
-    scroll-behavior: auto;
-  }
-
-  *,
-  *::before,
-  *::after {
-    scroll-behavior: auto !important;
-    transition-duration: 0.01ms !important;
-    animation-duration: 0.01ms !important;
-    animation-iteration-count: 1 !important;
+  .post-list li {
+    gap: 0.2rem;
   }
 }
 
 @media print {
+  html,
   body,
-  main {
+  .site-frame {
     background: #fff;
-    color: #000;
   }
 
   .site-frame {
     display: block;
-    width: 100%;
-  }
-
-  main {
-    padding: 0;
     border: 0;
-    box-shadow: none;
   }
 
   .site-header,
+  .site-sidebar,
   .site-footer,
-  .backlinks,
-  .related,
-  .heading-anchor {
+  .content-path {
     display: none;
+  }
+
+  .site-main {
+    padding: 0;
+  }
+
+  a {
+    color: #000;
   }
 }
@@ -38,4 +38,4 @@ [tool.setuptools.packages.find] include = ["mdssg*"]
 
 [tool.setuptools.package-data]
-"mdssg.theme" = ["*.html", "*.css"]
+"mdssg.theme" = ["*.html", "*.css", "*.svg"]
@@ -172,6 +172,27 @@ self.build()         css = self.output("assets/style.css")
         self.assertIn("--paper", css)
 
+    def test_logo_is_emitted(self):
+        self.write("content/index.md", "body")
+        self.build()
+        logo = self.output("assets/logo.svg")
+        self.assertIn('fill="#000080"', logo)
+
+    def test_graph_is_always_emitted_and_linked(self):
+        self.write("content/index.md", "---\ntitle: Home\n---\n\nSee [[One]].")
+        self.write("content/one.md", "---\ntitle: One\n---\n")
+        self.build()
+        graph = self.output("graph/index.html")
+        self.assertIn('class="graph-map"', graph)
+        self.assertIn('href="/one/"', graph)
+        self.assertIn('href="/graph/"', self.output("index.html"))
+
+    def test_graph_route_cannot_be_overwritten_by_content(self):
+        self.write("content/graph.md", "---\ntitle: Graph\n---\n")
+        with self.assertRaises(BuildError) as caught:
+            self.build()
+        self.assertIn("/graph/ is reserved", str(caught.exception))
+
     def test_static_files_are_copied(self):
         self.write("content/index.md", "body")
         self.write("static/robots.txt", "User-agent: *\n")
@@ -235,6 +256,7 @@ )         sitemap = self.output("sitemap.xml")
         self.assertIn("https://example.com/", sitemap)
         self.assertIn("https://example.com/a/", sitemap)
+        self.assertIn("https://example.com/graph/", sitemap)
 
     def test_sitemap_excludes_noindex_pages(self):
         self.write(
@@ -38,6 +38,13 @@ self.write("content/index.md", "---\ntitle: Home\n---\n")         self.index()
         self.assertFalse((self.root / "public" / "index.html").exists())
 
+    def test_index_only_writes_and_routes_the_graph(self):
+        self.write("content/index.md", "---\ntitle: Home\n---\n")
+        self.index()
+        routes = json.loads((self.root / ".mdssg" / "routes.json").read_text(encoding="utf-8"))
+        self.assertEqual(routes["routes"]["/graph/"]["kind"], "graph")
+        self.assertTrue((self.root / "public" / "graph" / "index.html").is_file())
+
     def test_seeds_are_routed(self):
         self.write("content/a.md", "---\ntitle: A\n---\n\nAbout [[Ghost]].")
         self.index()
@@ -67,6 +74,12 @@ def test_serves_the_home_page(self):         _, head, body = self.request("/")
         self.assertIn("200 OK", head)
         self.assertIn("Start at", body)
+
+    def test_serves_the_graph(self):
+        _, head, body = self.request("/graph/")
+        self.assertIn("200 OK", head)
+        self.assertIn('class="graph-map"', body)
+        self.assertIn('href="/kant/"', body)
 
     def test_links_resolve_without_the_graph(self):
         _, _, body = self.request("/kant/")
@@ -88,6 +88,11 @@     def test_it_demonstrates_related_notes(self):
         self.assertIn(">Related</h2>", self.read("seed-pages/index.html"))
 
+    def test_it_has_a_global_graph(self):
+        html = self.read("graph/index.html")
+        self.assertIn('class="graph-map"', html)
+        self.assertIn("nodes,", html)
+
     def test_folders_do_not_reach_urls(self):
         # concepts/wikilinks.md and reference/markdown.md, both flat.
         self.assertTrue((self.output / "wikilinks" / "index.html").is_file())
@@ -290,7 +290,7 @@     def test_every_bundled_template_compiles(self):
         loader = template.Loader()
         for name in ("base.html", "page.html", "index.html", "seed.html",
-                     "_backlinks.html", "_related.html"):
+                     "graph.html", "_backlinks.html", "_related.html"):
             with self.subTest(name=name):
                 self.assertTrue(loader.get(name).nodes or loader.get(name).extends)
 
@@ -60,6 +60,8 @@ checks += 1 
         css = (site / "public" / "assets" / "style.css").read_text(encoding="utf-8")
         assert "--paper" in css, "the bundled stylesheet did not survive packaging"
+        logo = (site / "public" / "assets" / "logo.svg").read_text(encoding="utf-8")
+        assert 'fill="#000080"' in logo, "the bundled logo did not survive packaging"
         checks += 1
 
     print(f"mdssg.pyz ok ({mdssg.__version__}, {checks} checks)")