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:
134
frontend/src/client/paliadin-render.ts
Normal file
134
frontend/src/client/paliadin-render.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// 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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user