feat: rich markdown renderer with LaTeX support for server web UI (#65)

* feat: rich markdown renderer with LaTeX support for server web UI

Extract markdown rendering from app.js into dedicated renderer.js with
full GFM support: tables (alignment, hover, striping), nested lists,
task list checkboxes, nested blockquotes, images (click-to-load for
privacy), and inline/display LaTeX math via self-hosted KaTeX 0.16.38.

Security: escape image/link URLs to prevent attribute injection, block
javascript: scheme in links, add rel="noopener noreferrer", images
require explicit click to load (no automatic external requests).

Accessibility: scope="col" on table headers, tabindex on scrollable
table containers, aria-labels on task checkboxes and image placeholders,
KaTeX error color override for WCAG AA contrast, reduced-motion support.

* fix: address code review — XSS hardening and list type splitting

- Escape all text through escapeHtml() at start of inlineMarkdown()
  so only renderer-generated tags appear in innerHTML (prevents raw
  HTML/script injection from LLM output)
- Replace inline onclick handler on image placeholders with data-*
  attributes and delegated DOM event listeners (prevents entity
  decoding XSS in event handler attributes)
- Split list blocks into separate <ul>/<ol> when marker type changes
  at the same indent level (mixed ordered/unordered sequences)
This commit is contained in:
Patrick Buckley
2026-03-15 00:04:01 -07:00
committed by GitHub
parent 3658b77de8
commit 2ef8a8711b
31 changed files with 478 additions and 144 deletions
+4 -2
View File
@@ -91,14 +91,16 @@ turnstone/
_config.py Base ChannelConfig dataclass
discord/ Discord adapter (bot, cog, views, streaming, config)
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
katex-0.16.38/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
ui/
colors.py ANSI color constants with NO_COLOR support
markdown.py Streaming terminal markdown renderer (line-buffered)
spinner.py Braille character spinner (daemon thread)
static/
index.html Single-page app shell (links to CSS and JS)
style.css Page-specific UI styles (dashboard layout, approval blocks)
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
app.js Page-specific client-side JavaScript (SSE, workstreams, tool approval)
tools/
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
```
+1
View File
@@ -75,6 +75,7 @@ package "turnstone/ui/" <<Rectangle>> {
component [colors.py\nANSI colors] as colors <<ui>>
component [markdown.py\nMD rendering] as markdown <<ui>>
component [spinner.py\nTerminal spinner] as spinner <<ui>>
component [renderer.js\nBrowser MD + LaTeX] as renderer <<ui>>
}
' API schemas
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c9daca81971ba7a8ed6736d23d5373c69435158fa6240b9880d14fc4759ab580
size 329673
oid sha256:efcc7cbe8161a54b5ec24bdfd47e8a142f70029e6e66c707e811b99369f85ebf
size 310079
+1
View File
@@ -76,6 +76,7 @@ include = [
"turnstone/console/static/*.js",
"turnstone/shared_static/*.css",
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.16.38/**/*",
"turnstone/sdk/py.typed",
]
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-138
View File
@@ -246,144 +246,6 @@ document
}
});
// --- Markdown rendering (basic regex, no external libs) ---
function renderMarkdown(text) {
// Protect code blocks first
const codeBlocks = [];
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function (m, lang, code) {
codeBlocks.push(
'<pre><code class="lang-' +
escapeHtml(lang) +
'">' +
escapeHtml(code.replace(/\n$/, "")) +
"</code></pre>",
);
return "\x00CB" + (codeBlocks.length - 1) + "\x00";
});
// Protect inline code
const inlineCodes = [];
text = text.replace(/`([^`\n]+)`/g, function (m, code) {
inlineCodes.push("<code>" + escapeHtml(code) + "</code>");
return "\x00IC" + (inlineCodes.length - 1) + "\x00";
});
// Process block-level elements per line
const lines = text.split("\n");
const out = [];
let inList = false;
let listType = "";
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Horizontal rule
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
if (inList) {
out.push(listType === "ul" ? "</ul>" : "</ol>");
inList = false;
}
out.push("<hr>");
continue;
}
// Headers
const hm = line.match(/^(#{1,6})\s+(.+)/);
if (hm) {
if (inList) {
out.push(listType === "ul" ? "</ul>" : "</ol>");
inList = false;
}
const level = hm[1].length;
out.push(
"<h" + level + ">" + inlineMarkdown(hm[2]) + "</h" + level + ">",
);
continue;
}
// Blockquote
if (line.startsWith("> ")) {
if (inList) {
out.push(listType === "ul" ? "</ul>" : "</ol>");
inList = false;
}
out.push(
"<blockquote>" + inlineMarkdown(line.slice(2)) + "</blockquote>",
);
continue;
}
// Unordered list
const ulm = line.match(/^(\s*)[-*+]\s+(.+)/);
if (ulm) {
if (!inList || listType !== "ul") {
if (inList) out.push(listType === "ul" ? "</ul>" : "</ol>");
out.push("<ul>");
inList = true;
listType = "ul";
}
out.push("<li>" + inlineMarkdown(ulm[2]) + "</li>");
continue;
}
// Ordered list
const olm = line.match(/^(\s*)\d+[.)]\s+(.+)/);
if (olm) {
if (!inList || listType !== "ol") {
if (inList) out.push(listType === "ul" ? "</ul>" : "</ol>");
out.push("<ol>");
inList = true;
listType = "ol";
}
out.push("<li>" + inlineMarkdown(olm[2]) + "</li>");
continue;
}
// Close list if we hit a non-list line
if (inList && line.trim() === "") {
out.push(listType === "ul" ? "</ul>" : "</ol>");
inList = false;
}
// Paragraph / plain text
if (line.trim() === "") {
out.push("");
} else {
out.push("<p>" + inlineMarkdown(line) + "</p>");
}
}
if (inList) out.push(listType === "ul" ? "</ul>" : "</ol>");
let result = out.join("\n");
// Restore code blocks and inline code
result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) {
return codeBlocks[parseInt(idx)];
});
result = result.replace(/\x00IC(\d+)\x00/g, function (m, idx) {
return inlineCodes[parseInt(idx)];
});
return result;
}
function inlineMarkdown(text) {
// Bold
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
text = text.replace(/__(.+?)__/g, "<strong>$1</strong>");
// Italic
text = text.replace(/\*(.+?)\*/g, "<em>$1</em>");
text = text.replace(/_(.+?)_/g, "<em>$1</em>");
// Strikethrough
text = text.replace(/~~(.+?)~~/g, "<del>$1</del>");
// Links
text = text.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank">$1</a>',
);
return text;
}
// === Tab / Workstream management ===
function renderTabBar() {
+3
View File
@@ -8,6 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/base.css">
<link rel="stylesheet" href="/shared/katex-0.16.38/katex.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
@@ -125,6 +126,8 @@ window.TURNSTONE_KB_SHORTCUTS = [
<script src="/shared/theme.js"></script>
<script src="/shared/auth.js"></script>
<script src="/shared/kb.js"></script>
<script src="/shared/katex-0.16.38/katex.min.js"></script>
<script src="/static/renderer.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+405
View File
@@ -0,0 +1,405 @@
// renderer.js — Markdown + LaTeX rendering (no external deps except KaTeX)
// ---------------------------------------------------------------------------
// Inline formatting
// ---------------------------------------------------------------------------
function inlineMarkdown(text) {
// Escape HTML first so only tags we generate are real
text = escapeHtml(text);
// Bold (asterisks only — underscores cause false positives on snake_case)
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Italic (asterisks only)
text = text.replace(
/(?<!\*)\*([^\s*](?:.*?[^\s*])?)\*(?!\*)/g,
"<em>$1</em>",
);
// Strikethrough
text = text.replace(/~~(.+?)~~/g, "<del>$1</del>");
// Images (must come before links — render as click-to-load placeholder)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (m, alt, url) {
var safeAlt = alt || "Image";
var domain = "";
try {
domain = escapeHtml(new URL(url).hostname);
} catch (e) {
domain = url.length > 40 ? url.slice(0, 40) + "…" : url;
}
return (
'<span class="img-placeholder" tabindex="0" role="button" ' +
'aria-label="Load image: ' +
safeAlt +
'" ' +
'data-src="' +
url +
'" data-alt="' +
safeAlt +
'">' +
'<span class="img-placeholder-icon">&#x1F5BC;</span> ' +
'<span class="img-placeholder-label">' +
safeAlt +
"</span>" +
'<span class="img-placeholder-domain">' +
domain +
"</span>" +
"</span>"
);
});
// Links (block javascript: scheme)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, url) {
if (/^\s*javascript:/i.test(url)) return m;
return (
'<a href="' +
url +
'" target="_blank" rel="noopener noreferrer">' +
label +
"</a>"
);
});
return text;
}
// Attach click-to-load listener for image placeholders (delegated)
document.addEventListener("click", function (e) {
var ph = e.target.closest(".img-placeholder");
if (!ph) return;
var img = document.createElement("img");
img.src = ph.getAttribute("data-src");
img.alt = ph.getAttribute("data-alt");
img.loading = "lazy";
ph.replaceWith(img);
});
document.addEventListener("keydown", function (e) {
if (e.key !== "Enter") return;
var ph = e.target.closest(".img-placeholder");
if (!ph) return;
ph.click();
});
// ---------------------------------------------------------------------------
// List rendering (nested + task lists)
// ---------------------------------------------------------------------------
function renderListBlock(items) {
if (items.length === 0) return "";
var minIndent = items[0].indent;
for (var i = 1; i < items.length; i++) {
if (items[i].indent < minIndent) minIndent = items[i].indent;
}
// Split into separate lists when marker type changes at top indent level
var segments = [];
var cur = [items[0]];
for (var i = 1; i < items.length; i++) {
if (items[i].indent <= minIndent && items[i].ordered !== cur[0].ordered) {
segments.push(cur);
cur = [items[i]];
} else {
cur.push(items[i]);
}
}
segments.push(cur);
if (segments.length > 1) {
return segments.map(renderListBlock).join("\n");
}
var type = items[0].ordered ? "ol" : "ul";
var html = "<" + type + ">";
var i = 0;
while (i < items.length) {
var item = items[i];
if (item.indent <= minIndent) {
var content = item.content;
// Task list checkboxes
var taskMatch = content.match(/^\[([ xX])\]\s*(.*)/);
if (taskMatch) {
var checked = taskMatch[1] !== " ";
content =
'<input type="checkbox" disabled' +
(checked ? " checked" : "") +
' aria-label="' +
escapeHtml(taskMatch[2]) +
'"> ' +
inlineMarkdown(taskMatch[2]);
} else {
content = inlineMarkdown(content);
}
// Collect children (deeper indent items following this one)
var children = [];
var j = i + 1;
while (j < items.length && items[j].indent > minIndent) {
children.push(items[j]);
j++;
}
if (children.length > 0) {
html += "<li>" + content + renderListBlock(children) + "</li>";
} else {
html += "<li>" + content + "</li>";
}
i = j;
} else {
i++;
}
}
html += "</" + type + ">";
return html;
}
// ---------------------------------------------------------------------------
// LaTeX rendering via KaTeX
// ---------------------------------------------------------------------------
function renderLatex(tex, displayMode) {
if (typeof katex === "undefined") return escapeHtml(tex);
try {
return katex.renderToString(tex, {
displayMode: displayMode,
throwOnError: false,
errorColor: "#f87171",
output: "html",
});
} catch (e) {
return '<code class="katex-error">' + escapeHtml(tex) + "</code>";
}
}
// ---------------------------------------------------------------------------
// Main markdown renderer
// ---------------------------------------------------------------------------
function renderMarkdown(text) {
// Pre-pass: extract blockquote blocks and recursively render.
// Must run FIRST (before code/math protection) so the recursive call
// processes raw markdown, not text with outer-scope placeholders.
var bqBlocks = [];
(function () {
var blines = text.split("\n");
var result = [];
var i = 0;
while (i < blines.length) {
if (blines[i].startsWith("> ") || blines[i] === ">") {
var inner = [];
while (
i < blines.length &&
(blines[i].startsWith("> ") || blines[i] === ">")
) {
inner.push(blines[i] === ">" ? "" : blines[i].slice(2));
i++;
}
bqBlocks.push(
"<blockquote>" + renderMarkdown(inner.join("\n")) + "</blockquote>",
);
result.push("\x00BQ" + (bqBlocks.length - 1) + "\x00");
} else {
result.push(blines[i]);
i++;
}
}
text = result.join("\n");
})();
// Protect code blocks
var codeBlocks = [];
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function (m, lang, code) {
codeBlocks.push(
'<pre><code class="lang-' +
escapeHtml(lang) +
'">' +
escapeHtml(code.replace(/\n$/, "")) +
"</code></pre>",
);
return "\x00CB" + (codeBlocks.length - 1) + "\x00";
});
// Protect display math ($$...$$) — must come before inline code/math
var mathBlocks = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) {
mathBlocks.push(renderLatex(tex.trim(), true));
return "\x00MB" + (mathBlocks.length - 1) + "\x00";
});
// Protect inline code
var inlineCodes = [];
text = text.replace(/`([^`\n]+)`/g, function (m, code) {
inlineCodes.push("<code>" + escapeHtml(code) + "</code>");
return "\x00IC" + (inlineCodes.length - 1) + "\x00";
});
// Protect inline math ($...$) — after inline code so `$x$` in code is safe
var inlineMaths = [];
text = text.replace(/\$([^\$\n]+?)\$/g, function (m, tex) {
inlineMaths.push(renderLatex(tex, false));
return "\x00IM" + (inlineMaths.length - 1) + "\x00";
});
// Protect markdown tables (extract before line-by-line processing)
var tableBlocks = [];
(function () {
var tlines = text.split("\n");
var sepRe = /^\|?(\s*:?-{1,}:?\s*\|)+\s*:?-{1,}:?\s*\|?\s*$/;
var result = [];
var i = 0;
while (i < tlines.length) {
if (
i + 1 < tlines.length &&
tlines[i].includes("|") &&
sepRe.test(tlines[i + 1])
) {
var headerLine = tlines[i];
var sepLine = tlines[i + 1];
var sepCells = sepLine
.replace(/^\|/, "")
.replace(/\|?\s*$/, "")
.split("|");
var aligns = sepCells.map(function (c) {
c = c.trim();
if (c.startsWith(":") && c.endsWith(":")) return "center";
if (c.endsWith(":")) return "right";
return "left";
});
var hdrCells = headerLine
.replace(/^\|/, "")
.replace(/\|?\s*$/, "")
.split("|")
.map(function (c) {
return c.trim();
});
var dataRows = [];
var j = i + 2;
while (
j < tlines.length &&
tlines[j].includes("|") &&
tlines[j].trim() !== ""
) {
var row = tlines[j]
.replace(/^\|/, "")
.replace(/\|?\s*$/, "")
.split("|")
.map(function (c) {
return c.trim();
});
dataRows.push(row);
j++;
}
var html =
'<div class="table-wrap" tabindex="0" role="region" aria-label="Data table"><table>';
html += "<thead><tr>";
for (var k = 0; k < hdrCells.length; k++) {
var align = aligns[k] || "left";
html +=
'<th scope="col" class="align-' +
align +
'">' +
inlineMarkdown(hdrCells[k]) +
"</th>";
}
html += "</tr></thead><tbody>";
for (var r = 0; r < dataRows.length; r++) {
html += "<tr>";
for (var k = 0; k < hdrCells.length; k++) {
var align = aligns[k] || "left";
var cell = dataRows[r][k] || "";
html +=
'<td class="align-' +
align +
'">' +
inlineMarkdown(cell) +
"</td>";
}
html += "</tr>";
}
html += "</tbody></table></div>";
tableBlocks.push(html);
result.push("\x00TB" + (tableBlocks.length - 1) + "\x00");
i = j;
} else {
result.push(tlines[i]);
i++;
}
}
text = result.join("\n");
})();
// Process block-level elements per line
var lines = text.split("\n");
var out = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Horizontal rule
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
out.push("<hr>");
continue;
}
// Headers
var hm = line.match(/^(#{1,6})\s+(.+)/);
if (hm) {
var level = hm[1].length;
out.push(
"<h" + level + ">" + inlineMarkdown(hm[2]) + "</h" + level + ">",
);
continue;
}
// Lists — collect consecutive list lines, then render with nesting
var ulm = line.match(/^(\s*)[-*+]\s+(.*)/);
var olm = !ulm ? line.match(/^(\s*)\d+[.)]\s+(.*)/) : null;
if (ulm || olm) {
var listItems = [];
while (i < lines.length) {
var um = lines[i].match(/^(\s*)[-*+]\s+(.*)/);
var om = !um ? lines[i].match(/^(\s*)\d+[.)]\s+(.*)/) : null;
if (um || om) {
var lm = um || om;
listItems.push({
indent: lm[1].length,
ordered: !!om,
content: lm[2],
});
i++;
} else {
break;
}
}
i--; // for-loop will increment
out.push(renderListBlock(listItems));
continue;
}
// Paragraph / plain text
if (line.trim() === "") {
out.push("");
} else {
out.push("<p>" + inlineMarkdown(line) + "</p>");
}
}
var result = out.join("\n");
// Restore protected blocks
result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) {
return codeBlocks[parseInt(idx)];
});
result = result.replace(/<p>\x00BQ(\d+)\x00<\/p>/g, function (m, idx) {
return bqBlocks[parseInt(idx)];
});
result = result.replace(/\x00BQ(\d+)\x00/g, function (m, idx) {
return bqBlocks[parseInt(idx)];
});
result = result.replace(/<p>\x00MB(\d+)\x00<\/p>/g, function (m, idx) {
return mathBlocks[parseInt(idx)];
});
result = result.replace(/\x00MB(\d+)\x00/g, function (m, idx) {
return mathBlocks[parseInt(idx)];
});
result = result.replace(/<p>\x00TB(\d+)\x00<\/p>/g, function (m, idx) {
return tableBlocks[parseInt(idx)];
});
result = result.replace(/\x00TB(\d+)\x00/g, function (m, idx) {
return tableBlocks[parseInt(idx)];
});
result = result.replace(/\x00IC(\d+)\x00/g, function (m, idx) {
return inlineCodes[parseInt(idx)];
});
result = result.replace(/\x00IM(\d+)\x00/g, function (m, idx) {
return inlineMaths[parseInt(idx)];
});
return result;
}
+39 -2
View File
@@ -240,12 +240,18 @@
/* ==========================================================================
Markdown styling
========================================================================== */
.msg-assistant h1, .msg-assistant h2, .msg-assistant h3 { color: var(--accent); margin: 8px 0 4px; font-family: var(--font-display); }
/* Override KaTeX body rule — our layout requires body as a static flex container */
body { position: static; }
.msg-assistant h1, .msg-assistant h2, .msg-assistant h3, .msg-assistant h4, .msg-assistant h5, .msg-assistant h6 { color: var(--accent); margin: 8px 0 4px; font-family: var(--font-display); }
.msg-assistant h1 { font-size: 18px; }
.msg-assistant h2 { font-size: 16px; }
.msg-assistant h3 { font-size: 14px; }
.msg-assistant h4 { font-size: 13px; font-weight: 600; }
.msg-assistant h5 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.msg-assistant h6 { font-size: 12px; font-weight: 500; color: var(--fg-dim); }
.msg-assistant strong { color: var(--fg-bright); }
.msg-assistant em { color: var(--magenta); }
.msg-assistant del { color: var(--fg-dim); text-decoration: line-through; }
.msg-assistant code {
background: var(--code-bg);
padding: 2px 6px;
@@ -268,6 +274,36 @@
.msg-assistant blockquote { border-left: 3px solid var(--accent-dim); padding-left: 12px; color: var(--fg-dim); margin: 6px 0; }
.msg-assistant hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
.msg-assistant p { margin: 4px 0; }
.msg-assistant .table-wrap { overflow-x: auto; margin: 8px 0; -webkit-overflow-scrolling: touch; }
.msg-assistant .table-wrap:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.msg-assistant table { border-collapse: collapse; width: auto; min-width: 50%; font-size: 13px; }
.msg-assistant thead { border-bottom: 2px solid var(--border-strong); }
.msg-assistant th { background: var(--code-bg); font-weight: 600; color: var(--fg-bright); }
.msg-assistant th, .msg-assistant td { padding: 6px 12px; border: 1px solid var(--border-strong); }
.msg-assistant tbody tr { transition: background 0.12s ease; }
.msg-assistant tbody tr:nth-child(even) { background: var(--row-alt); }
.msg-assistant tbody tr:hover { background: var(--bg-highlight); }
.msg-assistant .align-left { text-align: left; }
.msg-assistant .align-center { text-align: center; }
.msg-assistant .align-right { text-align: right; }
.msg-assistant img { display: block; max-width: 100%; height: auto; border-radius: var(--radius); margin: 4px 0; }
.msg-assistant .img-placeholder {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 14px; margin: 4px 0;
background: var(--code-bg); border: 1px solid var(--border-strong);
border-radius: var(--radius); cursor: pointer;
transition: border-color 0.12s ease;
}
.msg-assistant .img-placeholder:hover { border-color: var(--accent); }
.msg-assistant .img-placeholder:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.msg-assistant .img-placeholder-icon { font-size: 16px; }
.msg-assistant .img-placeholder-label { color: var(--fg-bright); font-size: 13px; }
.msg-assistant .img-placeholder-domain { color: var(--fg-dim); font-size: 11px; }
.msg-assistant li > input[type="checkbox"] { margin-right: 6px; vertical-align: middle; accent-color: var(--accent); }
.msg-assistant blockquote blockquote { margin: 4px 0; border-left-color: var(--border-strong); }
.msg-assistant blockquote blockquote blockquote { border-left-color: var(--border); }
.msg-assistant .katex-display { margin: 8px 0; padding: 8px 0; overflow-x: auto; overflow-y: hidden; }
.msg-assistant .katex-error { color: var(--red) !important; font-size: 12px; }
/* ==========================================================================
Input area
@@ -787,5 +823,6 @@
#plan-buttons button, #input-area button,
.dashboard-new-btn, .dashboard-input,
#health-indicator, #hamburger-btn,
#mcp-status { transition: none; }
#mcp-status, .msg-assistant tbody tr,
.msg-assistant .img-placeholder { transition: none; }
}