diff --git a/public/knowledge.js b/public/knowledge.js
index dcae405..376f867 100644
--- a/public/knowledge.js
+++ b/public/knowledge.js
@@ -3,22 +3,170 @@
const count = document.getElementById("knowledge-count");
const entries = Array.from(document.querySelectorAll("[data-knowledge-entry]"));
- if (!(input instanceof HTMLInputElement) || !count) return;
+ if (input instanceof HTMLInputElement && count) {
+ function update() {
+ const query = input.value.trim().toLowerCase();
+ let visible = 0;
- function update() {
- const query = input.value.trim().toLowerCase();
- let visible = 0;
+ for (const entry of entries) {
+ const matches = !query || (entry.getAttribute("data-search") || "").includes(query);
+ entry.hidden = !matches;
+ if (matches) visible += 1;
+ }
- for (const entry of entries) {
- const matches = !query || (entry.getAttribute("data-search") || "").includes(query);
- entry.hidden = !matches;
- if (matches) visible += 1;
+ count.textContent = query
+ ? `${visible} of ${entries.length} pages`
+ : `${entries.length} pages`;
}
- count.textContent = query
- ? `${visible} of ${entries.length} pages`
- : `${entries.length} pages`;
+ input.addEventListener("input", update);
}
- input.addEventListener("input", update);
+ const preview = document.getElementById("knowledge-preview");
+ const previewTitle = document.getElementById("knowledge-preview-title");
+ const previewMeta = document.getElementById("knowledge-preview-meta");
+ const previewSummary = document.getElementById("knowledge-preview-summary");
+ const previewExcerpt = document.getElementById("knowledge-preview-excerpt");
+ const previewSources = Array.from(document.querySelectorAll("[data-knowledge-preview-source]"));
+
+ if (
+ preview &&
+ previewTitle &&
+ previewMeta &&
+ previewSummary &&
+ previewExcerpt &&
+ previewSources.length > 0
+ ) {
+ const normalizePath = (path) => path.length > 1 ? path.replace(/\/$/, "") : path;
+ const sourceByPath = new Map(previewSources.map((source) => [
+ normalizePath(source.getAttribute("data-preview-href") || ""),
+ source,
+ ]));
+ let activeLink;
+
+ function sourceForTarget(target) {
+ if (!(target instanceof Element)) return;
+ const link = target.closest("a[href]");
+ if (!(link instanceof HTMLAnchorElement)) return;
+
+ const url = new URL(link.href, window.location.href);
+ if (url.origin !== window.location.origin) return;
+ const source = sourceByPath.get(normalizePath(url.pathname));
+ return source ? { link, source } : undefined;
+ }
+
+ function showPreview(link, source) {
+ if (activeLink === link) return;
+ activeLink = link;
+ previewTitle.textContent = source.getAttribute("data-preview-title") || "";
+ previewMeta.textContent = source.getAttribute("data-preview-meta") || "";
+ previewSummary.textContent = source.getAttribute("data-preview-summary") || "";
+ previewExcerpt.textContent = source.getAttribute("data-preview-excerpt") || "";
+ preview.setAttribute("data-active", "");
+ preview.setAttribute("aria-hidden", "false");
+ }
+
+ function hidePreview(link) {
+ if (link && activeLink !== link) return;
+ activeLink = undefined;
+ preview.removeAttribute("data-active");
+ preview.setAttribute("aria-hidden", "true");
+ }
+
+ document.addEventListener("pointerover", (event) => {
+ const target = sourceForTarget(event.target);
+ if (target) showPreview(target.link, target.source);
+ });
+
+ document.addEventListener("pointerout", (event) => {
+ const target = sourceForTarget(event.target);
+ if (!target) return;
+ if (event.relatedTarget instanceof Node && target.link.contains(event.relatedTarget)) return;
+ hidePreview(target.link);
+ });
+
+ document.addEventListener("focusin", (event) => {
+ const target = sourceForTarget(event.target);
+ if (target) showPreview(target.link, target.source);
+ });
+
+ document.addEventListener("focusout", (event) => {
+ const target = sourceForTarget(event.target);
+ if (target) hidePreview(target.link);
+ });
+ }
+
+ const contents = document.getElementById("knowledge-contents");
+ const contentsList = document.getElementById("knowledge-contents-list");
+ const knowledgeMain = document.querySelector(".knowledge-overview, .knowledge-entry");
+
+ if (contents && contentsList && knowledgeMain) {
+ const headings = Array.from(knowledgeMain.querySelectorAll("h1, h2, h3"))
+ .filter((heading) => heading.textContent && !heading.closest(".knowledge-chart"));
+ const usedIds = new Set(Array.from(document.querySelectorAll("[id]"))
+ .map((element) => element.id)
+ .filter(Boolean));
+
+ function headingId(heading, index) {
+ if (heading.id) return heading.id;
+ const base = (heading.textContent || "section")
+ .toLowerCase()
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "") || `section-${index + 1}`;
+ let id = base;
+ let suffix = 2;
+ while (usedIds.has(id)) id = `${base}-${suffix++}`;
+ heading.id = id;
+ usedIds.add(id);
+ return id;
+ }
+
+ const links = headings.map((heading, index) => {
+ const item = document.createElement("li");
+ const link = document.createElement("a");
+ const id = headingId(heading, index);
+ item.dataset.level = heading.tagName.slice(1);
+ link.href = `#${id}`;
+ link.textContent = heading.textContent.trim();
+ item.append(link);
+ contentsList.append(item);
+ return { heading, link };
+ });
+
+ if (links.length > 1) {
+ contents.setAttribute("data-active", "");
+ contents.setAttribute("aria-hidden", "false");
+
+ if (window.location.hash.length > 1) {
+ const initialTarget = document.getElementById(decodeURIComponent(window.location.hash.slice(1)));
+ if (initialTarget) window.requestAnimationFrame(() => initialTarget.scrollIntoView());
+ }
+
+ let frame;
+ const updateCurrent = () => {
+ frame = undefined;
+ const threshold = window.innerHeight * 0.24;
+ let current = links[0];
+ for (const candidate of links) {
+ if (candidate.heading.getBoundingClientRect().top <= threshold) current = candidate;
+ else break;
+ }
+ for (const candidate of links) {
+ const active = candidate === current;
+ candidate.link.toggleAttribute("data-current", active);
+ if (active) candidate.link.setAttribute("aria-current", "location");
+ else candidate.link.removeAttribute("aria-current");
+ }
+ };
+ const requestUpdate = () => {
+ if (frame) return;
+ frame = window.requestAnimationFrame(updateCurrent);
+ };
+ window.addEventListener("scroll", requestUpdate, { passive: true });
+ window.addEventListener("resize", requestUpdate);
+ updateCurrent();
+ }
+ }
})();
diff --git a/public/site.css b/public/site.css
index 5e0580b..45af9f6 100644
--- a/public/site.css
+++ b/public/site.css
@@ -1638,6 +1638,148 @@ h1 {
white-space: nowrap;
}
+.knowledge-preview {
+ display: none;
+}
+
+.knowledge-contents {
+ display: none;
+}
+
+@media (min-width: 1240px) {
+ .knowledge-contents {
+ position: fixed;
+ inset-block: clamp(6.5rem, 12vh, 9rem) 3rem;
+ inset-inline-end: calc(50% + 23rem);
+ display: block;
+ box-sizing: border-box;
+ width: min(18rem, calc(50vw - 26rem));
+ overflow-y: auto;
+ padding-inline-end: 1.2rem;
+ border-inline-end: 1px solid var(--site-border);
+ opacity: 0;
+ visibility: hidden;
+ scrollbar-width: thin;
+ }
+
+ .knowledge-contents[data-active] {
+ opacity: 1;
+ visibility: visible;
+ }
+
+ .knowledge-contents > p {
+ margin: 0 0 0.8rem;
+ color: var(--site-text-muted);
+ font-size: 0.64rem;
+ font-weight: 650;
+ }
+
+ .knowledge-contents ol {
+ display: grid;
+ gap: 0.05rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ .knowledge-contents li[data-level="3"] {
+ padding-inline-start: 0.75rem;
+ }
+
+ .knowledge-contents a {
+ display: block;
+ padding: 0.28rem 0;
+ color: var(--site-text-muted);
+ font-size: 0.68rem;
+ line-height: 1.35;
+ text-decoration: none;
+ transition: color 120ms ease;
+ }
+
+ .knowledge-contents li[data-level="1"] a {
+ color: var(--site-text-secondary);
+ font-weight: 650;
+ }
+
+ .knowledge-contents a:hover,
+ .knowledge-contents a:focus-visible,
+ .knowledge-contents a[data-current] {
+ color: var(--site-link);
+ }
+
+ .knowledge-overview h1,
+ .knowledge-overview h2,
+ .knowledge-overview h3,
+ .knowledge-entry h1,
+ .knowledge-entry h2,
+ .knowledge-entry h3 {
+ scroll-margin-top: 2rem;
+ }
+
+ .knowledge-preview {
+ position: fixed;
+ inset-block: clamp(6.5rem, 12vh, 9rem) 3rem;
+ inset-inline-start: calc(50% + 23rem);
+ display: block;
+ box-sizing: border-box;
+ width: min(22rem, calc(50vw - 26rem));
+ overflow: hidden;
+ padding-inline-start: 1.2rem;
+ border-inline-start: 1px solid var(--site-border);
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 1.75rem), transparent 100%);
+ mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 1.75rem), transparent 100%);
+ transform: translateY(0.35rem);
+ transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
+ }
+
+ .knowledge-preview[data-active] {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+ }
+
+ .knowledge-preview h2 {
+ margin: 0;
+ font-family: var(--site-font-body);
+ font-size: clamp(1.05rem, 1.35vw, 1.35rem);
+ font-weight: 650;
+ line-height: 1.25;
+ letter-spacing: -0.02em;
+ overflow-wrap: anywhere;
+ }
+
+ .knowledge-preview-meta {
+ margin: 0.55rem 0 0;
+ color: var(--site-text-muted);
+ font-size: 0.62rem;
+ }
+
+ .knowledge-preview-summary {
+ margin: 1.2rem 0 0;
+ color: var(--site-text-secondary);
+ font-size: 0.78rem;
+ font-weight: 600;
+ line-height: 1.55;
+ }
+
+ .knowledge-preview-excerpt {
+ margin: 1rem 0 0;
+ color: var(--site-text-secondary);
+ font-size: 0.76rem;
+ line-height: 1.65;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .knowledge-preview,
+ .knowledge-contents a {
+ transition: none;
+ }
+}
+
.knowledge-empty,
.knowledge-error {
color: var(--site-text-muted);
diff --git a/src/components/knowledge-contents.tsx b/src/components/knowledge-contents.tsx
new file mode 100644
index 0000000..c5e76a6
--- /dev/null
+++ b/src/components/knowledge-contents.tsx
@@ -0,0 +1,13 @@
+export function KnowledgeContents() {
+ return (
+
+ );
+}
diff --git a/src/components/knowledge-entry.tsx b/src/components/knowledge-entry.tsx
index 9eab8e2..d38f60f 100644
--- a/src/components/knowledge-entry.tsx
+++ b/src/components/knowledge-entry.tsx
@@ -5,6 +5,7 @@ import {
type KnowledgeEntry as KnowledgeEntryData,
} from "../knowledge.ts";
import { KnowledgeBody } from "../knowledge-charts/body.tsx";
+import { KnowledgeContents } from "./knowledge-contents.tsx";
import { PageNav } from "./page-nav.tsx";
import { ProtocolLinks } from "./protocol-links.tsx";
@@ -89,6 +90,8 @@ export async function KnowledgeEntry({
/>
+