feat(web): markdown RP semantic coloring + paragraph reflow (v0.20.6)

Markdown rework, pass 1 (operator-directed):

- "quoted" dialogue → speech (bright --md-speech), *asterisk* → action/narration
  (muted-italic --md-action). Two tunable CSS vars that cascade through --fg-* so
  they auto-adapt to the light theme; plain text stays default narration.
- speech-wrap runs BEFORE the em/link passes so a generated class="…"/href="…"
  quote can't be mis-read as dialogue; straight + smart quotes; apostrophes are
  safe; unbalanced/half-streamed quotes stay uncolored until they close.
- fix the ugliest existing bug: single newlines were hard <br>s → now CommonMark
  soft-breaks (space); a hard break needs 2+ trailing spaces or a trailing \.
- INV-004 escape-first preserved.

Verified: pytest tests/test_web_* (84) + a 10-case Playwright unit-check of
markdownSafe (speech / action / attribute-quote trap / apostrophes / mixed /
reflow / hard-break / html-escaping / unbalanced / paragraphs) + a visual render.
This commit is contained in:
2026-07-07 01:03:45 -07:00
parent 7704959f48
commit c988f273b1
4 changed files with 40 additions and 5 deletions
+1
View File
@@ -169,6 +169,7 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-07-06]` **Web UI: pivot from incremental CSS polish to a designed prototype (Claude Design) that I wire into.** Operator saw an Australis polish pass ("looks fine, but we're attacking it differently") and chose the prototype route — a designer builds the visual shell, I wire real data/SSE into its DOM. Authored the full design brief `docs/design/ratatoskr-web-design-brief.md` (complete information inventory of every pane/datum/state + Australis direction + single-file/no-CDN/vanilla wire-ability constraints). **Tracking surface:** the brief file + Claude Design project `bc0b65d1-a33e-422a-8bc1-3635c9112775` (file `Ratatoskr Console.dc.html`). Import mechanism = the `DesignSync` MCP; blocked on `/design-login` (claude.ai design scopes) — see Current state for the post-auth wiring plan.
- `[2026-07-07]` **Affect-egress reference delivered by worldtree-dev + a we-framing render DRIFT-WATCH banked.** worldtree-dev shipped `docs/affect-egress-consumer-reference.md` (`74d2408`, their origin/main) — the authoritative DELIVERED-on-wire vs HIDDEN (system-prompt-only) map for our affect surface. Confirms the v0.20.x console consumes it correctly: DELIVERED via affect.emit = pad + relations[RelationEdge] + dominant_emotion (**type-only, b23**; the #204 `affect_update` SSE is SUPPRESSED for Tier-3, so its richer `emotions_active` is Tier-1-only — we don't get it, and correctly poll our own affect store post-turn instead). HIDDEN render OUTPUTS are deterministically reconstructable from the canon; we reconstruct only the two FULLY-reconstructable (mood descriptor via `canonMood`, relationship directive via `canonDirective`) and SKIP the PARTIAL mood-directive (dominant_emotion is type-only/no-intensity → can't tell if the OCC directive fires at salience≥0.2 vs the PAD-band fallback) — honest per INV-001. Vendoring the ref doc as a `tolerate_drift` pin is SURFACED to Vuong (worldtree-dev will co-sign + honor a ping-on-change handshake, same as the d2-canon). **WE-FRAMING DRIFT-WATCH → STOOD DOWN (worldtree-dev 2026-07-07, `01KWXMQPHN…`).** The b24 3-gate we-framing conditional (drop "; avoid premature we-framing" under unsafe_capable+deep-warmth+expressive) was **REVERTED** — Vuong reframed it. So **`canonDirective` STAYS UNCONDITIONAL** (always appends the clause), which is CORRECT: it matches the currently-deployed renderer (b23) AND my pinned `affect-egress-consumer-reference.md` §2d (the doc reverted too — nothing changed on the wire or in my pin; NO re-vendor needed). The HOLD was right → ZERO rework. **NEW forward-watch (replaces this one):** the we-framing gate was a SYMPTOM — the render bakes enterprise safety-guards into the directive strings, so even `full`-tier characters get DEFANGED emotions (a hostile villain told to "keep a firm emotional boundary"). Fix = a **use-case-segregated persona render** (assistant / companion / RP-gaming), now a fresh **brokkr deep-research epic**. When it lands + is implemented, the render behavior for character/RP agents (→ our `canonDirective` + `canonEmotionDirective` reconstruction + the vendored d2 canons) will change MATERIALLY — worldtree-dev re-engages ratatoskr-dev then with the new reconstruction spec + a coordinated re-vendor. Until then: NO action, reconstruction stays as-is. [my unsafe_capable/mood_tier heuristic — character→full, agent→safe — was confirmed correct + is banked for whenever tier-gating returns]
- `[2026-07-07]` **Markdown pass-1 SHIPPED (`v0.20.6`) — RP semantic coloring + paragraph reflow (operator-directed markdown rework, step ①).** The transcript renderer (`markdownSafe`/`mdInline`) now colors the two roleplay registers: `"quoted"` dialogue → SPEECH (bright `--md-speech`=fg-0), `*asterisk*` → ACTION/narration (muted-italic `--md-action`=fg-3, on `em.md-action`); both tunable via 2 CSS vars that cascade through `--fg-*` so they auto-adapt to the light theme. Plain text stays default narration. **KEY ORDERING:** speech-wrap runs BEFORE the em/link passes so a generated `class="md-action"` / `href="…"` quote can't be mis-read as dialogue (adversarially verified). Straight + smart quotes; apostrophes don't trigger; unbalanced/half-streamed quotes stay uncolored until they close. ALSO fixed the ugliest existing bug: single newlines were hard-`<br>`s → now CommonMark soft-breaks (space); a hard break needs 2+ trailing spaces or a trailing `\`. INV-004 escape-first preserved (html inside a quote escaped). Verified: 10-case Playwright unit-check of `markdownSafe` (speech/action/attr-trap/apostrophes/mixed/reflow/hard-break/escaping/unbalanced/paragraphs) all green + a visual render. Patch bump. **Markdown rework remaining: pass-2 (tables / nested lists / ordered-list numbering / streaming robustness); + the MEMORY VIEWER (step ②, console pane recommended) still queued.**
- `[2026-07-07]` **Affect-derived tooltips (`v0.20.5`) + transparent squirrel vendored + v0.20.0.5 PUSHED to origin (operator-authorized).** Native `title` hints on all 6 affect-derived cells (samples/updated/baseline P·A/drift Δv/volatility — each with meaning + Tier-1-vs-Tier-3 availability). Committed the transparent full-res brand mark at `docs/design/ratatoskr-mark.png` (1024², alpha; the bg-removed source the inlined favicon derives from — reproducible via the documented ImageMagick corner floodfill). The web-UI redesign arc (v0.20.0 Claude Design console → .1 sparkline-overflow+tooltips → .2 context-injection → .3 squirrel brand/favicon → .4 SVG sparklines+3D cube → .5 derived tooltips) is now all on `origin/main`.
- `[2026-07-07]` **SVG sparklines + 3D mood cube imported from the updated Claude Design prototype (`v0.20.4`) — operator: "the svg sparklines and the new 3d graph".** Re-pulled `Ratatoskr Console.dc.html` via `DesignSync get_file` (designer iterated the same project, +6KB). Adapted 3 SVG systems out of the `.dc.html` into vanilla, replacing the unicode-char sparklines: **(1)** per-PAD-fader VERTICAL strips (`stripPoints`, 26×132 SVG beside each bar — time down Y newest-at-bottom, value on X ±11, `stripFade{P,A,D}` gradient, dot at newest; this also nails the earlier "next to each meter" ask); **(2)** relation-row HORIZONTAL sparklines (`sparkPointsH`, 56×13, auto-scaled, `sparkFade` gradient + end dot — also kills the old unicode-overflow "n behind graph" for good since it's a fixed-width SVG); **(3)** the mood-orbit reworked from a 2D P×A scatter into a **3D ISOMETRIC P×A×D cube** (`proj3`: P right-down/A left-down/D up, 2:1 iso, center 62,66, scale 26 — REVERSE-DERIVED from the design's placeholder now-point + verified: `x=62+26P26A, y=66+13P+13A26D`), with the trajectory + pulsing now-marker + a drop line to the D=1 floor + a floor-shadow ellipse for depth. Gradients in one hidden `<defs>` svg. Removed the orphaned `sparkline()`/`_SPARK`. Contract amended. Playwright-verified (injected 24-sample history: 3 strips + 4 relation sparklines + the 3D cube trail/drop/floor all render; gradients resolve). Patch bump.
- `[2026-07-07]` **Brand mark + favicon → the aurora squirrel (`v0.20.3`), replacing the `ᛯ` rune.** Operator supplied `/home/lkraven/rata.png` (chibi cyan-green aurora squirrel + acorn). Removed the black background via ImageMagick corner flood-fill (`-fuzz 20% -floodfill` from all 4 corners — keeps the squirrel's interior black linework/eyes (not edge-connected) + the glow, drops only the connected background), downscaled 1024→80px + quantized-64-colors (~12KB base64), inlined as ONE `SQUIRREL` data-URI const in the JS wiring the favicon `<link id="favicon">` href + both `.brand-mark` imgs (rail brand-row + setup-card h1). `.brand-glyph` (font-rune) CSS replaced by `.brand-mark` (img, drop-shadow glow + breathe). Playwright-verified (both marks + favicon decode, naturalWidth>0; no brand-glyph left). Source PNG stays at `/home/lkraven/rata.png` (not committed — data-URI is self-contained + reproducible via the documented floodfill). Patch bump.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.20.5"
version = "0.20.6"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+37 -3
View File
@@ -54,6 +54,11 @@
--fg-muted: var(--aus-dark-50);
--fg-on-blue: var(--aus-black);
/* RP semantic-render colors (transcript). Retune these two to taste — they cascade
through the --fg-* indirection so they auto-adapt to the light theme. */
--md-speech: var(--fg-0); /* "quoted" dialogue — the said words, bright */
--md-action: var(--fg-3); /* *action* / narration — italic, receded */
/* ---- ratatoskr console surfaces (deeper than aus-black for monitor depth) ---- */
--rk-deep: #14161d; /* left rail / deepest well */
--rk-canvas: #171a23; /* page canvas */
@@ -300,6 +305,9 @@ body[data-theme="light"] #theme-toggle .i-moon { display: inline; }
.md-body .md-h4, .md-body .md-h5, .md-body .md-h6 { font-size: 14px; }
.md-body strong { color: var(--fg-0); }
.md-body em { font-style: italic; }
/* RP semantic registers: "quoted" dialogue vs *action* narration */
.md-body .md-speech { color: var(--md-speech); }
.md-body em.md-action { color: var(--md-action); }
.md-body code { font-family: var(--font-mono); font-size: .86em; background: var(--rk-well); color: var(--aus-bright-cyan); border: 1px solid var(--border-subtle); padding: 1px 6px; border-radius: 4px; }
.md-body pre.md-code { font-family: var(--font-mono); font-size: 12px; background: var(--rk-well); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 10px 13px; overflow-x: auto; color: var(--fg-1); margin: 8px 0; }
.md-body ul, .md-body ol { margin: 4px 0 10px; padding-left: 22px; }
@@ -698,10 +706,20 @@ function ts() {
// ---- safe live Markdown (escape-first, whitelist subset; no CDN) -----------
function mdInline(s) {
// Order matters: strong first, then SPEECH (must run before any pass that emits a
// quoted attribute — em's class="…" and the link's href="…" — or those quotes would
// be mis-read as dialogue), then em, then links.
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
s = s.replace(/(^|[^_\w])_([^_\n]+)_/g, "$1<em>$2</em>");
// "quoted" dialogue → speech (RP semantic color). Straight + smart quotes, balanced,
// within one line; the quote glyphs are kept inside the span. Unbalanced/half-streamed
// quotes have no closer → left uncolored until they close.
s = s.replace(/"([^"\n]*)"/g, '<span class="md-speech">"$1"</span>');
s = s.replace(/[“]([^”\n]*)[”]/g, '<span class="md-speech">“$1”</span>');
// *action* / _action_ → italic, tagged as RP action/narration (semantic color)
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em class="md-action">$2</em>');
s = s.replace(/(^|[^_\w])_([^_\n]+)_/g, '$1<em class="md-action">$2</em>');
// [text](url) — http(s) only, conservative charset (no quotes/brackets/ws)
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, text, url) =>
/^https?:\/\/[^\s"'<>)]+$/.test(url)
? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`
@@ -715,7 +733,23 @@ function markdownSafe(raw) {
s = s.replace(/`([^`\n]+)`/g, (m, body) => { ic.push(body); return ` IC${ic.length - 1} `; });
const out = [];
let para = [], list = null;
const flushPara = () => { if (para.length) { out.push(`<div class="md-p">${para.map(mdInline).join("<br>")}</div>`); para = []; } };
// CommonMark soft-break: a single newline inside a paragraph is a SPACE, not a line
// break (the old join("<br>") hard-broke every soft-wrapped line). A hard break needs
// the line to end in 2+ spaces or a backslash.
const flushPara = () => {
if (!para.length) return;
const seg = [];
let prevHard = false;
para.forEach((ln, i) => {
const hard = /( +|\\)\s*$/.test(ln);
const clean = ln.replace(/(\s*\\|\s{2,})\s*$/, "");
if (i > 0) seg.push(prevHard ? "<br>" : " ");
seg.push(mdInline(clean));
prevHard = hard;
});
out.push(`<div class="md-p">${seg.join("")}</div>`);
para = [];
};
const closeList = () => { if (list) { out.push(`</${list}>`); list = null; } };
for (const line of s.split("\n")) {
let m;
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.20.5"
version = "0.20.6"
source = { editable = "." }
dependencies = [
{ name = "httpx" },