Files
paliad/frontend/src/client/paliadin-render.ts
m fc048c578e fix(paliadin-widget): render markdown + chips in inline bubbles, fix lime-trigger contrast
m's dogfood findings on the inline drawer:

1. Assistant responses (markdown headings, bold, lists, [chip:nav:…]
   tokens, [#deadline-OPEN:<id>] tokens) showed as raw text. The widget
   was setting body.textContent and skipping the renderer. Extracted
   the standalone /paliadin page's pipeline into client/paliadin-render.ts
   (renderResponseHTML + chip helpers + block markdown parser) so both
   surfaces share one source of truth. The widget now feeds assistant
   bubbles through innerHTML; user bubbles still go through textContent
   (no point parsing the user's typed markup).

2. Floating trigger button rendered the sparkle glyph in white-on-lime
   in dark mode — color: var(--color-text) inherits the dark-mode light
   foreground and washes out completely on a lime background. Lime is
   inherently a light-background colour, so the trigger pins its
   foreground to --hlc-midnight in both themes.

Bubble CSS additions: assistant bubbles get white-space: normal (the
base pre-wrap rule was forcing every source newline to a literal break
and breaking <p>/<h2>/<ul> spacing) plus tight h2/h3/p/ul margins so
the rendered markdown reads as a chat bubble, not a doc page.
2026-05-08 20:31:44 +02:00

135 lines
3.9 KiB
TypeScript

// Shared Paliadin response renderer — used by both the standalone
// /paliadin page (client/paliadin.ts) and the inline drawer widget
// (client/paliadin-widget.ts). Extracted from paliadin.ts so the
// widget renders the same markdown + chips as the dedicated page
// without re-implementing the pipeline.
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
function chipURL(kind: string, id: string): string {
switch (kind) {
case "deadline":
case "frist":
return "/deadlines/" + id;
case "projekt":
case "project":
return "/projects/" + id;
case "termin":
case "appointment":
return "/appointments/" + id;
default:
return "#";
}
}
function chipLabel(kind: string): string {
switch (kind) {
case "deadline":
case "frist":
return "Frist öffnen";
case "projekt":
case "project":
return "Akte ansehen";
case "termin":
case "appointment":
return "Termin öffnen";
default:
return "öffnen";
}
}
function renderBlocks(escapedHtml: string): string {
const out: string[] = [];
let listItems: string[] = [];
let paraLines: string[] = [];
const flushList = () => {
if (listItems.length === 0) return;
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
listItems = [];
};
const flushPara = () => {
if (paraLines.length === 0) return;
out.push(`<p>${paraLines.join("<br>")}</p>`);
paraLines = [];
};
for (const rawLine of escapedHtml.split("\n")) {
const line = rawLine.trim();
if (line === "") {
flushList();
flushPara();
continue;
}
let m: RegExpMatchArray | null;
if ((m = line.match(/^###\s+(.+)$/))) {
flushList();
flushPara();
out.push(`<h3>${m[1]}</h3>`);
} else if ((m = line.match(/^##\s+(.+)$/))) {
flushList();
flushPara();
out.push(`<h2>${m[1]}</h2>`);
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
flushPara();
listItems.push(m[1]);
} else if (line.match(/^---+$/)) {
flushList();
flushPara();
out.push(`<hr>`);
} else {
flushList();
paraLines.push(line);
}
}
flushList();
flushPara();
return out.join("");
}
export function renderResponseHTML(raw: string): string {
let html = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const chipHTML: string[] = [];
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
let rendered = "";
if (kind && id) {
const url = chipURL(kind, id);
const label = chipLabel(kind);
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
} else if (chipKind === "nav") {
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
} else if (chipKind === "filter") {
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
}
if (!rendered) return "";
chipHTML.push(rendered);
return `CHIP${chipHTML.length - 1}`;
});
html = renderBlocks(html);
html = html.replace(MD_LINK_RE, (_m, text, url) => {
const ext = url.startsWith("http");
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
});
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
return html;
}