feat(ui): clickable document viewer — open any cited document in the browser from chat chips and the sources table
This commit is contained in:
+11
-41
@@ -65,46 +65,14 @@ const reducedMotion =
|
||||
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const SCROLL = reducedMotion ? "auto" : "smooth";
|
||||
|
||||
/* ---------- tiny, safe markdown renderer (no external libs, no CDN) ---------- */
|
||||
export function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
export function renderMarkdown(md) {
|
||||
// 1. Protect fenced code blocks.
|
||||
const codeBlocks = [];
|
||||
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
|
||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// 2. Escape everything else, then apply inline + block transforms.
|
||||
text = escapeHtml(text)
|
||||
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
||||
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
||||
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
||||
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
||||
|
||||
// 3. Paragraphs (double newline separated).
|
||||
text = text
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const b = block.trim();
|
||||
if (!b) return "";
|
||||
if (/^<(h\d|ul|ol|pre|li)/.test(b)) return b;
|
||||
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 4. Restore code blocks.
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
/* ---------- document viewer link (phase 10) ----------
|
||||
* Every cited document opens in the viewer, in a NEW tab. Both query
|
||||
* values are percent-encoded: real paths contain slashes and sometimes
|
||||
* spaces, which would otherwise corrupt the query string. (The renderer
|
||||
* renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic
|
||||
* script loaded by index.html and document.html before these modules.) */
|
||||
export function documentUrl(source, path) {
|
||||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||
}
|
||||
|
||||
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
|
||||
@@ -323,7 +291,9 @@ function appendSources(wrap, sources) {
|
||||
const chip = document.createElement("a");
|
||||
chip.className = "source-chip";
|
||||
chip.setAttribute("role", "listitem");
|
||||
chip.href = "/sources.html";
|
||||
chip.href = documentUrl(s.source, s.path);
|
||||
chip.target = "_blank"; // open the full document in a new tab
|
||||
chip.rel = "noopener";
|
||||
chip.textContent = label;
|
||||
chip.title = label;
|
||||
meta.appendChild(chip);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* Brain of Reese — document viewer (phase 10).
|
||||
*
|
||||
* Reads `source`/`path` query params, fetches the stateless content
|
||||
* endpoint (GET /api/documents/content — database only, no filesystem),
|
||||
* and renders:
|
||||
*
|
||||
* • md / markdown → the shared escape-first renderer (markdown.js) in a
|
||||
* ≤46rem centered column;
|
||||
* • any other → the raw content as a text node inside
|
||||
* <pre class="doc-raw"> (mono, horizontal scroll).
|
||||
*
|
||||
* XSS-safe by construction: markdown is escaped before transform, raw
|
||||
* formats are set via textContent, and every document-derived string
|
||||
* (title, badges, path) is written with textContent — never innerHTML.
|
||||
*
|
||||
* A missing document (unknown pair, missing params, network error) shows
|
||||
* the designed not-found card with a link back to the Sources page.
|
||||
*/
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const source = params.get("source") || "";
|
||||
const path = params.get("path") || "";
|
||||
|
||||
const titleEl = document.querySelector("#doc-title");
|
||||
const metaEl = document.querySelector("#doc-meta");
|
||||
const contentEl = document.querySelector("#doc-content");
|
||||
const notFoundEl = document.querySelector("#doc-not-found");
|
||||
const mainEl = document.querySelector("#main");
|
||||
const backLink = document.querySelector("#doc-back");
|
||||
|
||||
/* Back: prefer the browser's own history when there is one (the viewer was
|
||||
* opened from this tab's session); a fresh tab lands on the Sources page. */
|
||||
backLink.addEventListener("click", (e) => {
|
||||
if (window.history.length > 1) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function contentUrl(s, p) {
|
||||
return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
|
||||
}
|
||||
|
||||
function metaBadge(cls, text) {
|
||||
const el = document.createElement("span");
|
||||
el.className = cls;
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function render(doc) {
|
||||
titleEl.textContent = doc.title;
|
||||
document.title = `${doc.title} · Brain of Reese`;
|
||||
|
||||
const pathCode = document.createElement("code");
|
||||
pathCode.className = "doc-path";
|
||||
pathCode.textContent = doc.path;
|
||||
metaEl.replaceChildren(
|
||||
metaBadge("doc-source-badge", doc.source),
|
||||
metaBadge("format-badge", doc.format),
|
||||
pathCode,
|
||||
metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
|
||||
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
||||
);
|
||||
|
||||
notFoundEl.hidden = true;
|
||||
contentEl.replaceChildren();
|
||||
if (doc.format === "md" || doc.format === "markdown") {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "doc-md";
|
||||
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
|
||||
contentEl.appendChild(wrap);
|
||||
} else {
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "doc-raw";
|
||||
pre.textContent = doc.content; // text node: never parsed as HTML
|
||||
contentEl.appendChild(pre);
|
||||
}
|
||||
}
|
||||
|
||||
function showNotFound() {
|
||||
titleEl.textContent = "Document not found";
|
||||
document.title = "Document not found · Brain of Reese";
|
||||
metaEl.replaceChildren();
|
||||
contentEl.replaceChildren();
|
||||
notFoundEl.hidden = false;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
if (!source || !path) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const r = await fetch(contentUrl(source, path));
|
||||
if (!r.ok) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
render(await r.json());
|
||||
} catch {
|
||||
showNotFound();
|
||||
} finally {
|
||||
mainEl.focus(); // move focus to main on load (a11y)
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Brain of Reese — shared markdown renderer (no external libs, no CDN).
|
||||
*
|
||||
* Extracted from app.js (phase 10) so the chat page and the document
|
||||
* viewer share the exact same escape-first renderer: every character is
|
||||
* HTML-escaped before any markup transform runs, so document (or user)
|
||||
* content can never inject live HTML/XSS. Classic script on purpose:
|
||||
* index.html and document.html load it via a plain relative <script src>
|
||||
* and both module scripts (app.js / document.js) call the globals it
|
||||
* defines. Rendering behavior is unchanged from the original app.js copy.
|
||||
*/
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function renderMarkdown(md) {
|
||||
// 1. Protect fenced code blocks.
|
||||
const codeBlocks = [];
|
||||
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
|
||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// 2. Escape everything else, then apply inline + block transforms.
|
||||
text = escapeHtml(text)
|
||||
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
||||
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
||||
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
||||
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
||||
|
||||
// 3. Paragraphs (double newline separated).
|
||||
text = text
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const b = block.trim();
|
||||
if (!b) return "";
|
||||
if (/^<(h\d|ul|ol|pre|li)/.test(b)) return b;
|
||||
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 4. Restore code blocks.
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
}
|
||||
@@ -21,6 +21,12 @@ function fmtDate(iso) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Viewer link (phase 10) — same encoded URL the chat chips use; both query
|
||||
* values are percent-encoded (paths contain slashes, sometimes spaces). */
|
||||
export function documentUrl(source, path) {
|
||||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||
}
|
||||
|
||||
async function loadDocs() {
|
||||
let r;
|
||||
try {
|
||||
@@ -56,13 +62,30 @@ async function loadDocs() {
|
||||
|
||||
function makeRow(d) {
|
||||
const tr = document.createElement("tr");
|
||||
const cells = [d.source, d.path, d.title, String(d.chunks), fmtDate(d.indexed_at)];
|
||||
for (const value of cells) {
|
||||
|
||||
const sourceTd = document.createElement("td");
|
||||
sourceTd.textContent = d.source; // document-derived text — never innerHTML
|
||||
tr.appendChild(sourceTd);
|
||||
|
||||
// Path cell: a link to the document viewer (phase 10), full path as the
|
||||
// accessible/hover name (the column is ellipsized).
|
||||
const pathTd = document.createElement("td");
|
||||
pathTd.title = d.path; // full path on hover (column is ellipsized)
|
||||
const link = document.createElement("a");
|
||||
link.className = "doc-link";
|
||||
link.href = documentUrl(d.source, d.path);
|
||||
link.target = "_blank"; // open the full document in a new tab
|
||||
link.rel = "noopener";
|
||||
link.title = d.path; // full path as the link's hover/accessible name
|
||||
link.textContent = d.path;
|
||||
pathTd.appendChild(link);
|
||||
tr.appendChild(pathTd);
|
||||
|
||||
for (const value of [d.title, String(d.chunks), fmtDate(d.indexed_at)]) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = value; // document-derived text — never innerHTML
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tr.children[1].title = d.path; // full path on hover (column is ellipsized)
|
||||
return tr;
|
||||
}
|
||||
|
||||
|
||||
+177
-3
@@ -153,8 +153,10 @@ body::after {
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
/* 2px brand→cyan gradient hairline under the sticky header (phase 08). */
|
||||
.app-header::after {
|
||||
/* 2px brand→cyan gradient hairline under the sticky header (phase 08;
|
||||
shared by the app header and the document-viewer header, phase 10). */
|
||||
.app-header::after,
|
||||
.doc-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
@@ -326,7 +328,7 @@ body::after {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-chip:hover { background: #2a345f; }
|
||||
.source-chip:hover { background: #2a345f; text-decoration: underline; }
|
||||
|
||||
/* "Maybe try" chips under a deflected bubble (phase 04). Unlike the
|
||||
onboarding row (which scrolls horizontally on mobile), this group wraps
|
||||
@@ -547,6 +549,173 @@ body::after {
|
||||
.docs-table tbody tr:hover { background: var(--bg); }
|
||||
.docs-table tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ---------- Document viewer (phase 10) ---------- */
|
||||
.doc-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--surface);
|
||||
}
|
||||
.doc-header-inner {
|
||||
padding-block: 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target). */
|
||||
.doc-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--brand-ink); /* 6.9:1 on --brand-soft */
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-back:hover { background: #2a345f; }
|
||||
.doc-back svg { width: 16px; height: 16px; display: block; }
|
||||
.doc-title-block { min-width: 0; }
|
||||
#doc-title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Meta row: source badge · format badge · mono path · indexed · chunks. */
|
||||
.doc-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.doc-source-badge {
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--brand-ink);
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 0.02rem 0.55rem;
|
||||
}
|
||||
.format-badge {
|
||||
font-family: var(--mono);
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--brand-ink);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.02rem 0.45rem;
|
||||
}
|
||||
.doc-path {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
max-width: 26rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-chunks { font-family: var(--mono); }
|
||||
|
||||
.doc-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
#doc-content { display: flex; flex-direction: column; }
|
||||
.doc-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
|
||||
|
||||
/* Markdown: the centered, ≤46rem reading column (PLAN §7.1). */
|
||||
.doc-md {
|
||||
width: 100%;
|
||||
max-width: 46rem;
|
||||
margin-inline: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.5rem 1.75rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.doc-md p { margin: 0.35rem 0; }
|
||||
.doc-md h3 { margin: 1.1rem 0 0.4rem; font-size: 1.15rem; }
|
||||
.doc-md h4 { margin: 0.9rem 0 0.35rem; font-size: 1rem; }
|
||||
.doc-md > :first-child { margin-top: 0; }
|
||||
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
|
||||
.doc-md pre {
|
||||
background: #0d1120;
|
||||
color: #e6e9f2;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.doc-md code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
||||
.doc-md pre code { background: none; padding: 0; }
|
||||
|
||||
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
|
||||
.doc-raw {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Designed not-found state (no emoji — plain SVG mark, phase 08 rule). */
|
||||
.doc-not-found {
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
margin: 2rem auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 2.25rem 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.doc-not-found-glyph { color: var(--ink-soft); width: 44px; height: 44px; margin-inline: auto; }
|
||||
.doc-not-found-glyph svg { width: 44px; height: 44px; display: block; }
|
||||
.doc-not-found h2 { margin: 0.8rem 0 0.4rem; font-size: 1.3rem; }
|
||||
.doc-not-found-sub { margin: 0 0 1.25rem; color: var(--ink-soft); }
|
||||
.doc-open-sources {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.doc-open-sources:hover { background: #7d88f5; }
|
||||
|
||||
/* Viewer links: Sources-table path cell + chat source chips (phase 10). */
|
||||
.doc-link {
|
||||
color: var(--brand-ink); /* 8.7:1 on --surface */
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-link:hover, .doc-link:focus-visible { text-decoration: underline; }
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.app-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -574,6 +743,11 @@ body::after {
|
||||
.suggestions { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start;
|
||||
padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
|
||||
.suggestion-chip { flex: 0 0 auto; }
|
||||
.doc-header-inner { flex-wrap: wrap; gap: 0.5rem; padding-block: 0.6rem; }
|
||||
#doc-title { font-size: 1.1rem; }
|
||||
.doc-path { max-width: 16rem; }
|
||||
.doc-md { padding: 1.1rem 1rem; }
|
||||
.doc-raw { padding: 1rem; font-size: 0.8rem; }
|
||||
.composer { padding: 0.5rem; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
||||
<title>Document · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="doc-header">
|
||||
<div class="container doc-header-inner">
|
||||
<a class="doc-back" id="doc-back" href="/sources.html">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Sources</span>
|
||||
</a>
|
||||
<div class="doc-title-block">
|
||||
<h1 id="doc-title">Loading…</h1>
|
||||
<div id="doc-meta" class="doc-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- aria-live wraps the load → content swap so screen readers hear the
|
||||
document land (phase 10 a11y contract). -->
|
||||
<div class="container doc-shell" aria-live="polite">
|
||||
<div id="doc-content">
|
||||
<p class="doc-loading" role="status">Loading document…</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-not-found" id="doc-not-found" hidden>
|
||||
<div class="doc-not-found-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"/><path d="M28 4v8h8"/><path d="m19 22 10 10M29 22l-10 10"/></svg>
|
||||
</div>
|
||||
<h2>Document not found</h2>
|
||||
<p class="doc-not-found-sub">
|
||||
This document isn't in the knowledge base — it may have been removed
|
||||
from the index, or the notes were re-imported.
|
||||
</p>
|
||||
<a class="doc-open-sources" href="/sources.html">Open Sources</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="assets/markdown.js"></script>
|
||||
<script type="module" src="assets/document.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -73,6 +73,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="assets/markdown.js"></script>
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user