fix(llm): chat panes never push the page sideways; markdown tables, fenced code, quotes and rules render; sticky autoscroll only when already at the bottom
Some checks failed
CI / lint (push) Successful in 32s
CI / notebooks-smoke (push) Successful in 1m36s
Deploy / notebooks (push) Has been skipped
CI / test (push) Successful in 2m13s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / zotero (push) Successful in 14s
Infra CI / docs (push) Successful in 1m32s
Infra CI / api (push) Successful in 1m13s
Infra CI / llm (push) Successful in 47s
Infra CI / mc (push) Failing after 33s
Deploy / report (push) Successful in 18s
Infra CI / notebooks (push) Successful in 5m32s

- .msg gets min-width: 0 and the lineage/valuation wrappers max-width:
  100%, so a wide table scrolls inside its message instead of
  stretching the column; #log hides horizontal overflow.
- long cited labels and the valuation descriptor / lineage event cells
  may wrap (they were nowrap); bubbles use overflow-wrap: anywhere.
- paint() handles | pipe | tables, ``` fences (also unterminated
  while streaming), > quotes and --- rules instead of showing them raw.
- streaming tokens and arriving panes only scroll the log when the
  reader was within 120px of the bottom; a new question still jumps.
- narrow viewports: header and input row wrap, user bubbles full width.
Verified with a Node DOM-stub harness over the page script and
tests/llm/test_api.py.
This commit is contained in:
kert
2026-09-24 17:22:26 -04:00
parent 055322f081
commit 47e0dba7d0

View File

@@ -44,18 +44,21 @@
text-transform: uppercase; letter-spacing: .08em; opacity: .8;
}
#log {
flex: 1 1 auto; overflow-y: auto; padding: 26px 22px;
flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 26px 22px;
display: flex; flex-direction: column; gap: 22px;
max-width: 860px; width: 100%; margin: 0 auto;
}
.msg { display: flex; flex-direction: column; gap: 7px; }
/* min-width: 0 lets a wide table pane scroll inside its message instead
of stretching the column (a flex child's default min-width is its
content width, which is what pushed the page sideways). */
.msg { display: flex; flex-direction: column; gap: 7px; min-width: 0; max-width: 100%; }
.msg .role {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted-fg);
}
.bubble {
padding: 14px 16px; white-space: pre-wrap; word-wrap: break-word;
border: 1px solid var(--border); border-radius: 3px;
padding: 14px 16px; white-space: pre-wrap; overflow-wrap: anywhere;
border: 1px solid var(--border); border-radius: 3px; max-width: 100%;
}
.user { align-items: flex-end; }
.user .role { text-align: right; }
@@ -65,10 +68,11 @@
}
.bot .bubble { background: var(--card); }
/* Model-cited [ID] tokens rendered as editorial small-caps mono. */
.bot .bubble .cite {
.bot .bubble .cite, .lineage-wrap .cite, .provenance .cite {
font-family: var(--font-mono); font-size: .82em; color: var(--accent);
background: var(--secondary); padding: 1px 5px; border-radius: 2px;
white-space: nowrap;
/* a 90-char label must be allowed to wrap rather than overflow */
white-space: normal; overflow-wrap: anywhere; box-decoration-break: clone;
}
/* A cite with a known paragraph URL is a jump link (FR ¶, eCFR §). */
.bot .bubble a.cite { text-decoration: underline dotted; cursor: pointer; }
@@ -84,6 +88,30 @@
.bot .bubble ul, .bot .bubble ol { margin: 0 0 .6em 1.2em; padding: 0; }
.bot .bubble li { margin: .15em 0; }
.bot .bubble code { font-family: var(--font-mono); font-size: .88em; }
.bot .bubble pre {
font-family: var(--font-mono); font-size: .85em; line-height: 1.4;
background: var(--secondary); border: 1px solid var(--border); border-radius: 3px;
padding: 10px 12px; margin: 0 0 .6em; overflow-x: auto; white-space: pre; max-width: 100%;
}
.bot .bubble pre code { font-size: inherit; }
.bot .bubble blockquote {
margin: 0 0 .6em; padding: 2px 12px; border-left: 3px solid var(--border);
color: var(--muted-fg);
}
.bot .bubble hr { border: 0; border-top: 1px solid var(--border); margin: .8em 0; }
/* Markdown tables the model writes (| a | b |) render as real tables in
a horizontally scrolling wrapper, never as raw pipe text. */
.bot .bubble .md-table { overflow-x: auto; max-width: 100%; margin: 0 0 .6em; }
.bot .bubble table.md {
border-collapse: collapse; font-size: 14px; white-space: normal;
}
.bot .bubble table.md th, .bot .bubble table.md td {
border: 1px solid var(--border); padding: 4px 8px; text-align: left; vertical-align: top;
}
.bot .bubble table.md th {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted-fg);
}
table.valuation {
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
font-variant-numeric: tabular-nums;
@@ -94,22 +122,28 @@
table.valuation th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted-fg); }
table.valuation td:nth-child(-n+3), table.valuation th:nth-child(-n+3) { text-align: left; }
/* the descriptor is the one cell that may wrap; numbers stay on one line */
table.valuation td:nth-child(2) { white-space: normal; min-width: 160px; max-width: 320px; }
table.valuation tr.proposed td { font-style: italic; opacity: .85; }
table.valuation caption {
caption-side: bottom; text-align: left; font-size: 11px;
color: var(--muted-fg); padding: 4px 2px 0;
}
.valuation-wrap { overflow-x: auto; }
.valuation-wrap { overflow-x: auto; max-width: 100%; }
table.lineage {
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
}
table.lineage th, table.lineage td {
border: 1px solid var(--border); padding: 4px 6px; text-align: left; white-space: nowrap;
vertical-align: top;
}
/* event text can carry long from → to code lists; let it wrap */
table.lineage td:nth-child(2), table.lineage td:nth-child(4) { white-space: normal; }
table.lineage td:nth-child(2) { max-width: 360px; }
table.lineage th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted-fg); }
tr.unanchored td { color: var(--muted, #888); font-style: italic; }
.lineage-wrap { overflow-x: auto; margin-bottom: 6px; }
.lineage-wrap { overflow-x: auto; max-width: 100%; margin-bottom: 6px; }
.lineage-wrap .caption { font-size: 12px; color: var(--muted-fg); margin-bottom: 2px; }
ul.elements, ul.guidance {
font-size: 12px; color: var(--muted-fg); margin: 6px 0 0; padding-left: 18px;
@@ -129,7 +163,7 @@
details.sources[open] > summary::before { content: "▾ "; }
.src {
border-left: 3px solid var(--border); padding: 6px 12px; margin: 10px 0;
color: var(--muted-fg);
color: var(--muted-fg); overflow-wrap: anywhere;
}
.src b { color: var(--accent); font-family: var(--font-mono); font-size: .8em; font-weight: 500; }
.err { color: var(--destructive); border-color: var(--destructive); }
@@ -176,6 +210,19 @@
margin: auto; max-width: 500px; text-align: center; color: var(--muted-fg);
font-style: italic;
}
/* Narrow viewports: the header and the input row wrap instead of
squeezing; a user bubble may use the full width. */
@media (max-width: 640px) {
header { flex-wrap: wrap; gap: 6px 14px; padding: 12px 14px; }
header .who { margin-left: 0; flex-basis: 100%; }
#log { padding: 16px 12px; gap: 16px; }
form { flex-wrap: wrap; padding: 12px; gap: 8px; }
textarea { flex: 1 1 100%; }
form label.recent, form label.mode { flex: 1 1 auto; }
button { flex: 0 0 auto; margin-left: auto; padding: 8px 18px; }
.user .bubble { max-width: 100%; }
table.valuation td:nth-child(2) { min-width: 120px; max-width: 200px; }
}
</style>
</head>
<body>
@@ -209,6 +256,16 @@
const box = document.getElementById('q');
const send = document.getElementById('send');
// Only follow the stream when the reader is already at the bottom —
// scrolling up to re-read an earlier answer must not be yanked back by
// every token or pane that arrives.
function nearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight < 120;
}
function stick(wasNear) {
if (wasNear) log.scrollTop = log.scrollHeight;
}
fetch('whoami').then(r => r.json()).then(d => {
if (d.user) document.getElementById('who').textContent = d.user;
}).catch(() => {});
@@ -263,10 +320,35 @@
// Block markdown: headings (# … ######), bullet and numbered lists,
// paragraphs. The model writes markdown; showing raw "## History" was
// the bug this replaces.
function isTableRow(line) {
return /^\s*\|.*\|\s*$/.test(line);
}
function isTableRule(line) {
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
}
function splitRow(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
}
function paintTable(el, rows, links) {
const wrap = document.createElement('div');
wrap.className = 'md-table';
const t = document.createElement('table');
t.className = 'md';
const [head, ...body] = rows;
const tr = t.createTHead().insertRow();
for (const cell of head) { const th = document.createElement('th'); paintInline(th, cell, links); tr.appendChild(th); }
const tb = t.createTBody();
for (const r of body) {
const row = tb.insertRow();
for (let i = 0; i < head.length; i++) { paintInline(row.insertCell(), r[i] || '', links); }
}
wrap.appendChild(t); el.appendChild(wrap);
}
function paint(el, text, links) {
el.innerHTML = '';
const lines = text.split('\n');
let para = [], list = null;
let para = [], list = null, table = null, fence = null, quote = null;
const flushPara = () => {
if (!para.length) return;
const p = document.createElement('p');
@@ -274,8 +356,47 @@
el.appendChild(p); para = [];
};
const flushList = () => { list = null; };
const flushTable = () => {
if (table && table.length) paintTable(el, table, links);
table = null;
};
const flushQuote = () => { quote = null; };
for (const raw of lines) {
const line = raw.replace(/\s+$/, '');
// fenced code: everything until the closing fence is verbatim
if (fence) {
if (/^\s*```/.test(line)) { fence = null; continue; }
fence.textContent += (fence.textContent ? '\n' : '') + raw;
continue;
}
if (/^\s*```/.test(line)) {
flushPara(); flushList(); flushTable(); flushQuote();
const pre = document.createElement('pre');
fence = document.createElement('code');
pre.appendChild(fence); el.appendChild(pre);
continue;
}
if (isTableRow(line)) {
flushPara(); flushList(); flushQuote();
if (isTableRule(line)) continue; // the |---|---| separator
if (!table) table = [];
table.push(splitRow(line));
continue;
}
flushTable();
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
flushPara(); flushList(); flushQuote();
el.appendChild(document.createElement('hr'));
continue;
}
const q = /^\s*>\s?(.*)$/.exec(line);
if (q) {
flushPara(); flushList();
if (!quote) { quote = document.createElement('blockquote'); el.appendChild(quote); }
const p = document.createElement('p'); paintInline(p, q[1], links); quote.appendChild(p);
continue;
}
flushQuote();
const h = /^(#{1,6})\s+(.*)$/.exec(line);
const li = /^\s*(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line);
if (h) {
@@ -297,7 +418,7 @@
flushList(); para.push(line);
}
}
flushPara();
flushPara(); flushTable();
}
// label → url for this turn, filled by the lineage/valuation/sources
@@ -321,6 +442,7 @@
}
function renderLineage(wrap, ev) {
const near = nearBottom();
const events = ev.events || [];
const box = document.createElement('div');
box.className = 'lineage-wrap';
@@ -413,7 +535,7 @@
box.appendChild(note);
}
wrap.appendChild(box);
log.scrollTop = log.scrollHeight;
stick(near);
}
const VAL_COLS = [
@@ -430,6 +552,7 @@
return String(v);
}
function renderValuation(wrap, ev) {
const near = nearBottom();
const rows = ev.rows || [];
const box = document.createElement('div');
box.className = 'valuation-wrap';
@@ -486,10 +609,11 @@
}
box.appendChild(prov);
wrap.appendChild(box);
log.scrollTop = log.scrollHeight;
stick(near);
}
function renderSources(wrap, ev) {
const near = nearBottom();
const sources = ev.sources || [];
if (ev.model) {
const meta = document.createElement('div');
@@ -532,7 +656,7 @@
d.appendChild(el);
}
wrap.appendChild(d);
log.scrollTop = log.scrollHeight;
stick(near);
}
async function ask(question) {
@@ -566,7 +690,7 @@
const line = part.replace(/^data: /, '').trim();
if (!line) continue;
const ev = JSON.parse(line);
if (ev.type === 'token') { answer += ev.text; paint(b, answer, links); log.scrollTop = log.scrollHeight; }
if (ev.type === 'token') { const near = nearBottom(); answer += ev.text; paint(b, answer, links); stick(near); }
else if (ev.type === 'lineage') { collectLinks(links, ev); renderLineage(wrap, ev); }
else if (ev.type === 'valuation') { collectLinks(links, ev); renderValuation(wrap, ev); }
else if (ev.type === 'sources') { collectLinks(links, ev); renderSources(wrap, ev); paint(b, answer, links); }