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.
This commit is contained in:
m
2026-05-08 20:31:44 +02:00
parent d0e8c995fe
commit fc048c578e
4 changed files with 200 additions and 159 deletions

View File

@@ -0,0 +1,134 @@
// 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;
}

View File

@@ -26,6 +26,7 @@
import { initI18n, getLang, t } from "./i18n";
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
import { startersFor, type Starter } from "./paliadin-starters";
import { renderResponseHTML } from "./paliadin-render";
interface MeResponse {
id: string;
@@ -404,7 +405,14 @@ function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
const body = document.createElement("div");
body.className = "paliadin-widget-bubble-text";
body.textContent = text;
// Assistant bubbles get the same markdown + chip pipeline as the
// standalone /paliadin page (client/paliadin-render.ts). User bubbles
// stay plain text — no need to interpret the user's typed markup.
if (role === "assistant") {
body.innerHTML = renderResponseHTML(text);
} else {
body.textContent = text;
}
wrap.appendChild(body);
messages?.appendChild(wrap);
if (messages) messages.scrollTop = messages.scrollHeight;
@@ -413,7 +421,14 @@ function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
function setBubbleText(bubble: HTMLElement, text: string): void {
const body = bubble.querySelector(".paliadin-widget-bubble-text");
if (body) body.textContent = text;
if (body) {
const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
if (isAssistant) {
(body as HTMLElement).innerHTML = renderResponseHTML(text);
} else {
body.textContent = text;
}
}
const messages = document.getElementById("paliadin-widget-messages");
if (messages) messages.scrollTop = messages.scrollHeight;
}

View File

@@ -1,5 +1,6 @@
import { initI18n, getLang, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { renderResponseHTML } from "./paliadin-render";
// Paliadin chat panel client (t-paliad-146 PoC).
//
@@ -339,162 +340,6 @@ function finishBubble(bubble: HTMLElement, data: any): void {
}
}
// Marker → button render. Mirrors §4.4 of the design.
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 renderResponseHTML(raw: string): string {
// First escape any HTML in the raw text (simple textContent → innerHTML
// would have been fine but we then need to inject anchors, so the
// manual escape is unavoidable).
let html = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// Stage 1: extract chip markers as placeholder sentinels so subsequent
// link-rendering passes don't try to re-parse the chip URLs as bare
// URLs and double-anchor them.
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}`;
});
// Stage 2: Block-level Markdown — headings (## / ###), unordered lists
// (- item), and paragraphs separated by blank lines. Done before the
// inline passes so the inline regexes only ever run inside a block.
// Chip SOH placeholders are inert text at this point and pass through
// untouched.
html = renderBlocks(html);
// Stage 3: Markdown links [text](url). Internal /paths stay same-tab;
// external http(s) URLs open in a new tab.
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>`;
});
// Stage 4: auto-link bare URLs. The leading-character class on the
// regex avoids matching URLs already inside an href attribute (preceded
// by `="`) and the prefix capture is preserved verbatim so we don't
// drop punctuation.
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
// Stage 5: inline emphasis. Bold first so the italic regex doesn't
// misparse `**bold**` as nested `*italic*`. Both bounded to single
// lines via [^*\n] to avoid runaway matches across paragraphs.
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
// Stage 4: substitute chip placeholders back. Done last so chip URLs
// never go through the link-rendering passes.
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
return html;
}
// renderBlocks parses the escaped html into block-level Markdown:
// `## H` → <h2>, `### H` → <h3>, `- item` lines → <ul><li>, blank-line
// separated runs → <p> with intra-paragraph newlines as <br>. Anything
// not matched falls through verbatim, so the function is a strict
// superset of the prior behaviour for plain-text responses.
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("");
}
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 saveHistory(): void {
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));

View File

@@ -12896,7 +12896,10 @@ dialog.quick-add-sheet::backdrop {
height: 56px;
border-radius: 50%;
background: var(--color-accent);
color: var(--color-text);
/* Lime is a light-background colour: foreground must be midnight in
both light + dark themes, otherwise the SVG glyph (currentColor)
washes out to white-on-lime in dark mode. */
color: var(--hlc-midnight);
border: 1px solid var(--color-border-strong);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
display: inline-flex;
@@ -13123,6 +13126,50 @@ dialog.quick-add-sheet::backdrop {
align-self: flex-start;
margin-right: auto;
background: var(--color-surface-2);
/* Assistant bubbles render markdown HTML (paliadin-render.ts) — pre-wrap
on the parent would force every newline in the source to a literal
break and clash with <p>/<h2>/<ul> spacing. User bubbles keep
pre-wrap from the base rule above. */
white-space: normal;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text > *:first-child {
margin-top: 0;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text > *:last-child {
margin-bottom: 0;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text h2 {
font-size: 1rem;
font-weight: 600;
margin: 0.6rem 0 0.3rem;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text h3 {
font-size: 0.95rem;
font-weight: 600;
margin: 0.5rem 0 0.25rem;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text p {
margin: 0.4rem 0;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text ul.paliadin-list {
margin: 0.4rem 0;
padding-left: 1.25rem;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text ul.paliadin-list li {
margin: 0.15rem 0;
}
.paliadin-widget-bubble--assistant .paliadin-widget-bubble-text hr {
border: 0;
border-top: 1px solid var(--color-border);
margin: 0.75rem 0;
}
.paliadin-widget-bubble--error {