@livx.cc/bare-v3 0.1.0-alpha.53 → 0.1.0-alpha.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ The comparison UI needs the fixture server for example links. Model outputs repl
17
17
 
18
18
  Install the published alpha with `bun add @livx.cc/bare-v3@alpha`; use `bun pm pack` for an unpublished local build. Import CSS from `@livx.cc/bare-v3/css` or `/css/min`. Put `class="b3-root"` on html and set `data-b3-theme="dark"` explicitly for dark mode. Component references are exported under `/references/*.md`; start with `/references/start.md`. Place generic link defaults in `@layer bare.reset` so they do not override button/navigation colors. Unlayered consumer CSS overrides library layers: scope custom palette colors to each theme rather than applying light-only colors unconditionally to `:root`.
19
19
 
20
- `bun lab plan config.json` creates a seeded, capped development job plan. `bun lab prepare run.json` prepares one isolated workspace and manifest without invoking a model. The [visual playground](http://127.0.0.1:4313/fixtures/playground.html) demonstrates the current component contracts and shows the bare-v2 coverage audit. Composed apps include Signal (system performance), Elsewhere (local discovery/booking), Daylight (iOS/Android web studies), Relay, Gather setup and sign-in, Counter, Margin, Still, Fieldnotes, Interval, and the Forma project workspace. New components are an implemented subset, not full bare-v2 parity or a qualified release. Optional interactions are exported from `@livx.cc/bare-v3/ui`; call `mount(document)` once and use its returned cleanup on teardown. Genuinely hard interactive widgets ship separately as light-DOM custom elements — currently `@livx.cc/bare-v3/elements/image-viewer` (`<b3-image-viewer>`) and `/elements/date-picker` (`<b3-date-picker>`). Read `recipes/image-viewer.md` or `recipes/date-picker.md` for fallback markup and lifecycle. The plain/React/Vue demos in `fixtures/elements/` exercise the image viewer; run `bun scripts/verify-image-viewer.ts` through the authorized AgentX browser workflow to verify measured transforms.
20
+ `bun lab plan config.json` creates a seeded, capped development job plan. `bun lab prepare run.json` prepares one isolated workspace and manifest without invoking a model. The [visual playground](http://127.0.0.1:4313/fixtures/playground.html) demonstrates the current component contracts and shows the bare-v2 coverage audit. Composed apps include Signal (system performance), Elsewhere (local discovery/booking), Daylight (iOS/Android web studies), Relay, Gather setup and sign-in, Counter, Margin, Still, Fieldnotes, Interval, and the Forma project workspace. New components are an implemented subset, not full bare-v2 parity or a qualified release. Optional interactions are exported from `@livx.cc/bare-v3/ui`; call `mount(document)` once and use its returned cleanup on teardown. Genuinely hard interactive widgets ship separately as light-DOM custom elements — currently `@livx.cc/bare-v3/elements/image-viewer` (`<b3-image-viewer>`) and `/elements/date-picker` (`<b3-date-picker>`), plus the `/elements/markdown-stream` module (`MarkdownStream`) for streaming LLM markdown into bare-v3 components. Read `recipes/image-viewer.md`, `recipes/date-picker.md` or `recipes/markdown-stream.md` for markup and lifecycle; `fixtures/elements/markdown-stream.html` demos the stream; `bun run chat` serves it as a live chat against a real model (needs `OPENROUTER_API_KEY` or `OPENAI_API_KEY`; offline it replays canned replies). The plain/React/Vue demos in `fixtures/elements/` exercise the image viewer; run `bun scripts/verify-image-viewer.ts` through the authorized AgentX browser workflow to verify measured transforms.
21
21
 
22
22
  Playground previews accept shareable app/width links, for example [Signal at 390px](http://127.0.0.1:4313/fixtures/playground.html?demo=metrics&width=390#demos). The controls keep these query parameters in sync; unknown values fall back to the authored defaults. This is an iframe layout preview, not mobile-device qualification.
23
23
 
@@ -0,0 +1,374 @@
1
+ // src/elements/markdown-stream.js
2
+ var esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
3
+ var parseAttrs = (s) => Object.fromEntries([...(s || "").matchAll(/(\w+)=(?:"([^"]*)"|(\S+))/g)].map((m) => [m[1], m[2] ?? m[3]]));
4
+ var isNum = (s) => /^[-+$€£]?[\d,.]+%?$/.test(String(s).trim());
5
+ var toneAttr = (tone) => tone ? ` data-b3-tone="${esc(tone)}"` : "";
6
+ var directives = {
7
+ callout(t, ctx) {
8
+ const title = t.attrs.title ? `<strong>${esc(t.attrs.title)}</strong>` : "";
9
+ return `<div class="b3-alert"${toneAttr(t.attrs.tone)}><div>${title}${ctx.parse(t.tokens)}</div></div>`;
10
+ },
11
+ stats(t, ctx) {
12
+ const inline = ctx?.inline ?? esc;
13
+ const cards = t.body.split(`
14
+ `).filter((l) => l.trim()).map((line) => {
15
+ const [label, value, note = ""] = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((s) => s.trim());
16
+ const tone = /^[↑+]/.test(note) ? "success" : /^[↓-]/.test(note) ? "danger" : "";
17
+ return `<div class="b3-stat b3-surface"><span class="b3-label">${inline(label)}</span><strong class="b3-stat-value">${value ? inline(value.replace(/^\*\*(.*)\*\*$/, "$1")) : "…"}</strong>${note ? `<span class="b3-badge"${toneAttr(tone)}>${inline(note)}</span>` : ""}</div>`;
18
+ });
19
+ return `<div class="b3-md-stats">${cards.join("")}</div>`;
20
+ }
21
+ };
22
+ var fences = {
23
+ chart(text) {
24
+ const rows = text.split(`
25
+ `).map((l) => l.split(":")).filter((p) => p.length > 1 && !isNaN(parseFloat(p[1])));
26
+ const max = Math.max(...rows.map((r) => parseFloat(r[1])), 1);
27
+ return `<figure class="b3-md-bars b3-surface">${rows.map(([k, v]) => `<div class="b3-md-bar"><span>${esc(k.trim())}</span><i style="inline-size:${Math.max(parseFloat(v), 0) / max * 100}%"></i><b>${esc(v.trim())}</b></div>`).join("")}</figure>`;
28
+ }
29
+ };
30
+ directives.callout.doc = '`:::callout{tone=info|success|warning|danger title="Short title"}` newline, markdown body, newline `:::` — a highlighted note, tip, warning or recommendation.';
31
+ directives.stats.doc = "`:::stats` newline, one card per line `Label | Value | note`, newline `:::` — headline numbers; a note starting with ↑ or + shows as good, ↓ or - as bad.";
32
+ fences.chart.doc = "```chart fence, one bar per line `Label: number` (plain number, no units) — a horizontal bar chart for comparing a few values.";
33
+ function promptGuide(options = {}) {
34
+ const all = { ...directives, ...options.directives, ...fences, ...options.fences };
35
+ const tones = Object.entries(options.tones ?? new MarkdownStreamOptions().tones).reduce((by, [word, tone]) => ((by[tone] ??= []).push(word), by), {});
36
+ return [
37
+ "Your replies are Markdown rendered live into UI components. Use GitHub-flavoured Markdown (headings, **bold**, lists, links, `inline code`, fenced code with a language, > quotes) plus:",
38
+ `- Tables: GFM pipe tables with a header row and a \`| --- |\` delimiter row. Never add a Status column the data does not already have. Only when a row really has one of these states (e.g. an invoice that is paid), write the plain word alone in the cell and it is shown as a coloured badge automatically (no images, emoji or badge URLs): ${Object.entries(tones).map(([tone, words]) => `${words.join(", ")} (${tone})`).join("; ")}. Numeric columns align right.`,
39
+ "- Task lists: `- [x] done` and `- [ ] to do`.",
40
+ ...Object.values(all).filter((render) => render?.doc).map((render) => `- ${render.doc}`),
41
+ "Use a component only when it makes the answer clearer; plain prose is fine. Put every block on its own lines with a blank line around it, always close ::: and ``` blocks, and never write raw HTML."
42
+ ].join(`
43
+ `);
44
+ }
45
+ var codeBlock = (text, lang) => `<div class="b3-md-code"><header><span class="b3-muted">${esc(lang || "text")}</span><button type="button" class="b3-button" data-b3-emphasis="quiet" data-b3-md-copy>Copy</button></header><pre><code>${esc(text)}</code></pre></div>`;
46
+ function settle(buf) {
47
+ let s = buf;
48
+ const lines = s.split(`
49
+ `);
50
+ const tail = [];
51
+ while (lines.length && /^\s*\|/.test(lines.at(-1)))
52
+ tail.unshift(lines.pop());
53
+ const complete = buf.endsWith(`
54
+ `) ? tail : tail.slice(0, -1);
55
+ let pendingTable = false;
56
+ if (tail.length && !complete.some((l) => /^\s*\|?\s*:?-{2,}/.test(l))) {
57
+ pendingTable = true;
58
+ s = lines.join(`
59
+ `);
60
+ }
61
+ s = s.replace(/(^|\n) {0,3}(`{3}[^\n]*|~{3}[^\n]*|:{3}[^\n]*|`{1,2}|~{1,2}|:{1,2})$/, "$1");
62
+ if ((s.match(/^ {0,3}(`{3}|~{3})/gm) || []).length % 2)
63
+ return { src: s, pendingTable };
64
+ s = s.replace(/\[([^\]\n]*)\]?\([^)\n]*$/, "$1").replace(/\[([^\]\n]*)$/, "$1").replace(/(\*{1,3}|_{1,3}|~{1,2}|`)$/, "");
65
+ const fenceEnd = [...s.matchAll(/^ {0,3}(`{3}|~{3})[^\n]*\n?/gm)].at(-1);
66
+ const para = s.slice(Math.max(s.lastIndexOf(`
67
+
68
+ `) + 1, fenceEnd ? fenceEnd.index + fenceEnd[0].length : 0));
69
+ let close = "";
70
+ for (const m of ["`", "~~"])
71
+ if (para.split(m).length % 2 === 0)
72
+ close = m + close;
73
+ close += openEmphasis(para);
74
+ return { src: close ? s.trimEnd() + close : s, pendingTable };
75
+ }
76
+ function openEmphasis(para) {
77
+ const bare = para.replace(/`[^`\n]*`?/g, "").replace(/\\[*_]/g, "").replace(/\]\([^)\n]*\)?|https?:\/\/\S*/g, "]");
78
+ const stack = [];
79
+ for (const { 0: run, index } of bare.matchAll(/\*+|_+/g)) {
80
+ const before = bare[index - 1] ?? " ", after = bare[index + run.length] ?? " ";
81
+ let n = run.length;
82
+ if (/\S/.test(before) && (run[0] === "*" || !/\w/.test(after))) {
83
+ while (n && stack.at(-1)?.[0] === run[0]) {
84
+ const top = stack.pop();
85
+ const k = Math.min(n, top.length);
86
+ n -= k;
87
+ if (top.length > k)
88
+ stack.push(top.slice(k));
89
+ }
90
+ }
91
+ const opens = /[\p{L}\p{N}]/u.test(after) || run.length > 1 && /\s/.test(before) && /^[^\s\w*_-]\S*$/.test(bare.slice(index + run.length));
92
+ if (n && opens && !(/\w/.test(before) && (run[0] === "_" || n === 1)))
93
+ stack.push(run.slice(0, n));
94
+ }
95
+ return stack.reverse().join("");
96
+ }
97
+ var ALERTS = { note: "info", info: "info", tip: "success", important: "info", warning: "warning", caution: "danger", danger: "danger" };
98
+ var STYLE_ID = "b3-markdown-stream-style";
99
+ var STYLE = `
100
+ .b3-md-appear{animation:b3-md-in var(--b3-duration,.2s) ease-out;}
101
+ @keyframes b3-md-in{from{opacity:0;transform:translateY(3px);}}
102
+ .b3-md-caret{display:inline-block;inline-size:.5em;block-size:1em;vertical-align:-.15em;margin-inline-start:2px;background:currentColor;animation:b3-md-blink 1s steps(2) infinite;}
103
+ @keyframes b3-md-blink{50%{opacity:0;}}
104
+ .b3-md-code{border:1px solid var(--b3-color-border);border-radius:var(--b3-radius);overflow:hidden;}
105
+ .b3-md-code>header{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.25rem .5rem .25rem .75rem;font-size:.8rem;background:var(--b3-color-surface);border-block-end:1px solid var(--b3-color-border);}
106
+ .b3-md-code>pre{margin:0;padding:.75rem;overflow-x:auto;}
107
+ .b3-md-bars{display:grid;gap:.4rem;margin-inline:0;padding:var(--b3-space,1rem);}
108
+ .b3-md-bar{display:grid;grid-template-columns:minmax(0,7rem) 1fr 3rem;gap:.5rem;align-items:center;font-size:.9rem;}
109
+ .b3-md-bar>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
110
+ .b3-md-bar>i{display:block;block-size:.7rem;border-radius:999px;background:var(--b3-color-accent);transition:inline-size var(--b3-duration,.2s);}
111
+ .b3-md-bar>b{text-align:end;font-variant-numeric:tabular-nums;}
112
+ .b3-md-stats{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(min(10rem,100%),1fr));}
113
+ .b3-md-tasks{list-style:none;padding-inline-start:0;}
114
+ .b3-md-tasks input{margin-inline-end:.5rem;vertical-align:-.1em;}
115
+ .b3-md-tasks>li>input+p{display:inline;}
116
+ .b3-md-num{text-align:end;font-variant-numeric:tabular-nums;}
117
+ .b3-md-pending{display:grid;gap:.5rem;}
118
+ .b3-md-pending>.b3-skeleton{block-size:1.5rem;}
119
+ .b3-md-pending>.b3-skeleton+.b3-skeleton{inline-size:80%;}
120
+ @media (prefers-reduced-motion:reduce){.b3-md-appear,.b3-md-caret{animation:none;}}
121
+ `;
122
+ function ensureStyle(document2) {
123
+ if (!document2?.head || document2.getElementById(STYLE_ID))
124
+ return;
125
+ const style = document2.createElement("style");
126
+ style.id = STYLE_ID;
127
+ style.textContent = STYLE;
128
+ document2.head.append(style);
129
+ }
130
+
131
+ class MarkdownStreamOptions {
132
+ Marked = null;
133
+ sanitize = null;
134
+ directives = directives;
135
+ fences = fences;
136
+ code = codeBlock;
137
+ tones = { paid: "success", done: "success", ok: "success", active: "success", pending: "warning", overdue: "danger", failed: "danger", blocked: "danger", draft: "info" };
138
+ html = "escape";
139
+ markedOptions = {};
140
+ }
141
+
142
+ class MarkdownStream {
143
+ el = null;
144
+ buf = "";
145
+ nodes = [];
146
+ ended = false;
147
+ #md;
148
+ #onClick = (event) => this.#copy(event);
149
+ constructor(options) {
150
+ this.options = { ...new MarkdownStreamOptions, ...options };
151
+ this.options.directives = { ...directives, ...options?.directives };
152
+ this.options.fences = { ...fences, ...options?.fences };
153
+ const { Marked, sanitize } = this.options;
154
+ if (typeof Marked !== "function")
155
+ throw new TypeError('MarkdownStream: options.Marked (from "marked") is required');
156
+ if (typeof sanitize !== "function")
157
+ throw new TypeError("MarkdownStream: options.sanitize is required (e.g. html => DOMPurify.sanitize(html))");
158
+ this.#md = this.#createParser();
159
+ }
160
+ attach(el) {
161
+ this.detach();
162
+ this.el = el;
163
+ this.buf = "";
164
+ this.nodes = [];
165
+ this.ended = false;
166
+ ensureStyle(el.ownerDocument);
167
+ el.addEventListener("click", this.#onClick);
168
+ return this;
169
+ }
170
+ detach() {
171
+ this.el?.removeEventListener("click", this.#onClick);
172
+ this.el = null;
173
+ }
174
+ push(chunk) {
175
+ if (this.ended) {
176
+ console.warn("MarkdownStream: push() after end() ignored; call attach(el) to start a new stream");
177
+ return;
178
+ }
179
+ this.buf += chunk;
180
+ this.render(true);
181
+ }
182
+ end() {
183
+ this.ended = true;
184
+ this.render(false);
185
+ }
186
+ toHtml(src) {
187
+ return this.options.sanitize(this.#md.parse(src));
188
+ }
189
+ render(streaming) {
190
+ const { el } = this;
191
+ if (!el)
192
+ throw new Error("MarkdownStream: call attach(el) before push()");
193
+ const doc = el.ownerDocument;
194
+ const { src, pendingTable } = streaming ? settle(this.buf) : { src: this.buf, pendingTable: false };
195
+ const tokens = this.#md.lexer(src).filter((t) => t.type !== "space");
196
+ tokens.forEach((tok, i) => {
197
+ const prev = this.nodes[i];
198
+ if (prev && prev.raw === tok.raw)
199
+ return;
200
+ const holder = doc.createElement("div");
201
+ holder.innerHTML = this.options.sanitize(this.#md.parser([tok]));
202
+ const only = holder.childElementCount === 1 && [...holder.childNodes].every((n) => n.nodeType === 1 || !n.textContent.trim()) ? holder.firstElementChild : null;
203
+ const node = only ?? holder;
204
+ if (!prev)
205
+ node.classList.add("b3-md-appear");
206
+ prev ? prev.el.replaceWith(node) : el.append(node);
207
+ this.nodes[i] = { raw: tok.raw, el: node };
208
+ });
209
+ this.nodes.splice(tokens.length).forEach((n) => n.el.remove());
210
+ el.querySelector(":scope .b3-md-caret, :scope > .b3-md-pending")?.remove();
211
+ if (pendingTable)
212
+ el.insertAdjacentHTML("beforeend", '<div class="b3-md-pending" aria-hidden="true"><div class="b3-skeleton"></div><div class="b3-skeleton"></div></div>');
213
+ else if (streaming) {
214
+ const last = this.nodes.at(-1)?.el;
215
+ const at = last?.matches("p, li, td, th, pre") ? last : last?.querySelector("p:last-of-type, li:last-child, td:last-child") || last || el;
216
+ at.insertAdjacentHTML("beforeend", '<span class="b3-md-caret" aria-hidden="true"></span>');
217
+ }
218
+ }
219
+ #copy(event) {
220
+ const button = event.target.closest?.("[data-b3-md-copy]");
221
+ if (!button || !this.el?.contains(button))
222
+ return;
223
+ const text = button.closest(".b3-md-code")?.querySelector("code")?.textContent ?? "";
224
+ const show = (label) => {
225
+ button.textContent = label;
226
+ setTimeout(() => {
227
+ button.textContent = "Copy";
228
+ }, 1200);
229
+ };
230
+ const fail = (error) => {
231
+ console.warn("MarkdownStream: copy failed", error);
232
+ show("Copy failed");
233
+ };
234
+ const clip = this.el.ownerDocument.defaultView?.navigator?.clipboard;
235
+ if (clip?.writeText)
236
+ clip.writeText(text).then(() => show("Copied"), fail);
237
+ else
238
+ fail(new Error("navigator.clipboard is unavailable (insecure context?)"));
239
+ }
240
+ #createParser() {
241
+ const opts = this.options;
242
+ const directive = {
243
+ name: "directive",
244
+ level: "block",
245
+ start: (src) => src.match(/^:::/m)?.index,
246
+ tokenizer(src) {
247
+ const m = /^:::(\w+)(?:\{([^}\n]*)\})?[^\n]*(?:\n|$)/.exec(src);
248
+ if (!m)
249
+ return;
250
+ const lines = src.slice(m[0].length).split(`
251
+ `);
252
+ let fence = null, end = lines.length;
253
+ for (let i = 0;i < lines.length; i++) {
254
+ const f = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i])?.[1];
255
+ if (fence) {
256
+ if (f && f[0] === fence[0] && f.length >= fence.length && !lines[i].trim().slice(f.length))
257
+ fence = null;
258
+ } else if (f)
259
+ fence = f;
260
+ else if (/^:::[^\S\n]*$/.test(lines[i])) {
261
+ end = i;
262
+ break;
263
+ }
264
+ }
265
+ const body = lines.slice(0, end).join(`
266
+ `).replace(/\n$/, "");
267
+ const closed = end < lines.length;
268
+ const raw = m[0] + lines.slice(0, closed ? end + 1 : end).join(`
269
+ `) + (closed && end + 1 < lines.length ? `
270
+ ` : "");
271
+ return { type: "directive", raw, name: m[1], attrs: parseAttrs(m[2]), body, tokens: this.lexer.blockTokens(body, []) };
272
+ },
273
+ renderer(t) {
274
+ const render = opts.directives[t.name] ?? opts.directives.callout;
275
+ return render(t, { parse: (tokens) => this.parser.parse(tokens), parseInline: (tokens) => this.parser.parseInline(tokens), inline: (text) => md.parseInline(text), esc });
276
+ }
277
+ };
278
+ const md = new opts.Marked({ gfm: true, extensions: [directive] });
279
+ md.use({
280
+ renderer: {
281
+ blockquote(t) {
282
+ const m = /^\s*(?:\[!(\w+)\][^\S\n]*\n?|\*\*(\w+):?\*\*:?[^\S\n]*)/.exec(t.text);
283
+ const kind = (m?.[1] ?? m?.[2])?.toLowerCase();
284
+ if (!Object.hasOwn(ALERTS, kind))
285
+ return false;
286
+ const title = kind[0].toUpperCase() + kind.slice(1);
287
+ return opts.directives.callout({ attrs: { tone: ALERTS[kind], title }, tokens: md.lexer(t.text.slice(m[0].length)) }, { parse: (tokens) => this.parser.parse(tokens), parseInline: (tokens) => this.parser.parseInline(tokens), inline: (text) => md.parseInline(text), esc });
288
+ },
289
+ table(t) {
290
+ const numeric = t.header.map((_, i) => t.rows.length > 0 && t.rows.every((r) => isNum(r[i]?.text || "0")));
291
+ const cell = (c, i, tag) => {
292
+ const html = this.parser.parseInline(c.tokens);
293
+ const tone = tag === "td" && opts.tones[c.text.trim().toLowerCase()];
294
+ const body = tone ? `<span class="b3-badge" data-b3-tone="${esc(tone)}">${html}</span>` : html;
295
+ return `<${tag}${tag === "th" ? ' scope="col"' : ""}${numeric[i] ? ' class="b3-md-num"' : ""}>${body}</${tag}>`;
296
+ };
297
+ return `<div class="b3-table-wrap" tabindex="0" role="region" aria-label="Table"><table class="b3-table"><thead><tr>${t.header.map((c, i) => cell(c, i, "th")).join("")}</tr></thead><tbody>${t.rows.map((r) => `<tr>${r.map((c, i) => cell(c, i, "td")).join("")}</tr>`).join("")}</tbody></table></div>`;
298
+ },
299
+ code({ text, lang }) {
300
+ const name = (lang || "").trim().split(/\s/)[0];
301
+ const fence = Object.hasOwn(opts.fences, name) ? opts.fences[name] : null;
302
+ return fence ? fence(text, { esc, lang: name }) : opts.code(text, name);
303
+ },
304
+ html(t) {
305
+ return opts.html === "render" ? false : t.block ? `<p>${esc(t.text.trim())}</p>` : esc(t.text);
306
+ },
307
+ list(t) {
308
+ if (!t.items.some((i) => i.task))
309
+ return false;
310
+ return `<ul class="b3-md-tasks">${t.items.map((i) => {
311
+ const tokens = i.tokens.filter((k) => k.type !== "checkbox");
312
+ const head = tokens.find((k) => k.type === "text" || k.type === "paragraph");
313
+ if (head?.tokens)
314
+ head.tokens = head.tokens.filter((k) => k.type !== "checkbox");
315
+ const body = tokens.map((k) => k.type === "text" && !i.loose ? this.parser.parseInline(k.tokens ?? [k]) : this.parser.parse([k])).join("");
316
+ return `<li><input type="checkbox" disabled${i.checked ? " checked" : ""}>${body}</li>`;
317
+ }).join("")}</ul>`;
318
+ }
319
+ }
320
+ });
321
+ if (opts.markedOptions && Object.keys(opts.markedOptions).length)
322
+ md.use(opts.markedOptions);
323
+ return md;
324
+ }
325
+ }
326
+ function stickToBottom(scroller = document.scrollingElement, { threshold = 80 } = {}) {
327
+ const doc = scroller.ownerDocument ?? scroller;
328
+ const win = doc.defaultView;
329
+ const target = scroller === doc.scrollingElement ? win : scroller;
330
+ let pinned = true, lastTop = scroller.scrollTop;
331
+ const measure = () => {
332
+ const top = scroller.scrollTop;
333
+ if (scroller.scrollHeight - top - scroller.clientHeight >= threshold)
334
+ pinned = false;
335
+ else if (top >= lastTop)
336
+ pinned = true;
337
+ lastTop = top;
338
+ };
339
+ const up = (e) => {
340
+ if (e.target?.closest?.('input, textarea, select, [contenteditable]:not([contenteditable="false"])'))
341
+ return;
342
+ if (scroller.scrollTop > 0 && (e.deltaY < 0 || /^(ArrowUp|PageUp|Home)$/.test(e.key)))
343
+ pinned = false;
344
+ };
345
+ const on = [["scroll", measure], ["wheel", up], ["keydown", up]];
346
+ on.forEach(([type, f]) => target.addEventListener(type, f, { passive: true }));
347
+ return {
348
+ get pinned() {
349
+ return pinned;
350
+ },
351
+ stick(force = false) {
352
+ if (force || pinned) {
353
+ scroller.scrollTop = scroller.scrollHeight;
354
+ pinned = true;
355
+ }
356
+ },
357
+ destroy() {
358
+ on.forEach(([type, f]) => target.removeEventListener(type, f));
359
+ }
360
+ };
361
+ }
362
+ export {
363
+ MarkdownStream,
364
+ MarkdownStreamOptions,
365
+ codeBlock,
366
+ directives,
367
+ esc,
368
+ fences,
369
+ isNum,
370
+ parseAttrs,
371
+ promptGuide,
372
+ settle,
373
+ stickToBottom
374
+ };
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-alpha.53",
2
+ "version": "0.1.0-alpha.57",
3
3
  "cssSha256": "5b3fec9da13833361cfa59510dd9bcf25719455f1b20cf6f7692d58a45303fe7",
4
4
  "classes": [
5
5
  "b3-accordion",
@@ -473,8 +473,10 @@
473
473
  "editable-filtered-rows.md",
474
474
  "icons.md",
475
475
  "image-viewer.md",
476
+ "markdown-stream.md",
476
477
  "motion.md",
477
478
  "scroll-entry.md",
478
- "start.md"
479
+ "start.md",
480
+ "markdown-stream-prompt.md"
479
481
  ]
480
482
  }
@@ -0,0 +1,13 @@
1
+ # Markdown stream: model prompt
2
+
3
+ System-prompt snippet from `promptGuide()` in `@livx.cc/bare-v3/elements/markdown-stream`.
4
+
5
+ ~~~~text
6
+ Your replies are Markdown rendered live into UI components. Use GitHub-flavoured Markdown (headings, **bold**, lists, links, `inline code`, fenced code with a language, > quotes) plus:
7
+ - Tables: GFM pipe tables with a header row and a `| --- |` delimiter row. Never add a Status column the data does not already have. Only when a row really has one of these states (e.g. an invoice that is paid), write the plain word alone in the cell and it is shown as a coloured badge automatically (no images, emoji or badge URLs): paid, done, ok, active (success); pending (warning); overdue, failed, blocked (danger); draft (info). Numeric columns align right.
8
+ - Task lists: `- [x] done` and `- [ ] to do`.
9
+ - `:::callout{tone=info|success|warning|danger title="Short title"}` newline, markdown body, newline `:::` — a highlighted note, tip, warning or recommendation.
10
+ - `:::stats` newline, one card per line `Label | Value | note`, newline `:::` — headline numbers; a note starting with ↑ or + shows as good, ↓ or - as bad.
11
+ - ```chart fence, one bar per line `Label: number` (plain number, no units) — a horizontal bar chart for comparing a few values.
12
+ Use a component only when it makes the answer clearer; plain prose is fine. Put every block on its own lines with a blank line around it, always close ::: and ``` blocks, and never write raw HTML.
13
+ ~~~~
@@ -0,0 +1,91 @@
1
+ # Streaming markdown
2
+
3
+ `MarkdownStream` renders an LLM's streaming markdown into bare-v3 components. Finished blocks are
4
+ frozen (their DOM is never replaced, so selection and copy buttons stay put), only the tail
5
+ re-renders, and half-written syntax never shows raw. Opt-in; not part of `ui.js`.
6
+
7
+ It bundles no parser or sanitizer: pass marked's `Marked` class and a sanitize function.
8
+
9
+ ```js
10
+ import { Marked } from 'marked';
11
+ import DOMPurify from 'dompurify';
12
+ import { MarkdownStream, stickToBottom } from '@livx.cc/bare-v3/elements/markdown-stream';
13
+
14
+ const view = new MarkdownStream({ Marked, sanitize: (html) => DOMPurify.sanitize(html) });
15
+ const scroll = stickToBottom(); // pins while streaming; the reader scrolling up un-pins
16
+ view.attach(document.querySelector('article.b3-prose'));
17
+ for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) { view.push(chunk); scroll.stick(); }
18
+ view.end();
19
+ ```
20
+
21
+ ## What renders
22
+
23
+ Any markdown-speaking model works with no prompt: standard markdown is upgraded on its own. The `:::` directives and `chart` fence are extras a model only writes once taught (see `promptGuide` below).
24
+
25
+ | Markdown | Renders as |
26
+ | --- | --- |
27
+ | GFM table | `b3-table` in `b3-table-wrap`; status words (paid, pending, overdue…) become toned `b3-badge`s; all-numeric columns align end |
28
+ | `:::callout{tone=warning title="…"}` … `:::` | `b3-alert` with tone and title; body is markdown |
29
+ | `:::stats` lines `Label \| Value \| ↑ note` `:::` | `b3-stat` cards; `↑`/`+` notes success, `↓`/`-` danger |
30
+ | ```` ```chart ```` lines `Label: number` | horizontal bars scaled to the max |
31
+ | ```` ```lang ```` | code block with language label and Copy button |
32
+ | `- [x] task` | list with disabled checkboxes |
33
+ | `> [!NOTE]` / `> [!WARNING]` (GitHub alerts) or `> **Note:** …` | `b3-alert`; note/important → info, tip → success, warning → warning, caution/danger → danger |
34
+
35
+ ## Options
36
+
37
+ `Marked`, `sanitize` (both required), `directives` (name → `(token, ctx) => html`; unknown names
38
+ fall back to `callout`), `fences` (lang → `(text, ctx) => html`), `code` (`(text, lang) => html`),
39
+ `tones` (cell text → tone), `markedOptions` (extra `marked.use()` input), `html` (`'escape'` default |
40
+ `'render'`). Maps merge over the built-ins, so adding one renderer keeps the rest. Everything a renderer
41
+ returns is sanitized.
42
+
43
+ - **Raw HTML is shown as text by default**: `Use <Button variant="x">` or `a <div> … </div>` in model
44
+ output (coding agents write JSX) renders as literal tags, not live DOM. `html: 'render'` passes it
45
+ through `sanitize` instead, for hosts whose content is trusted HTML.
46
+ - **Footnotes and other GFM extras** are not built in; add a marked extension via `markedOptions`, e.g.
47
+ `markedOptions: markedFootnote()` (from `marked-footnote`) for `Note[^1]` / `[^1]: x`.
48
+
49
+ Methods: `attach(el)`, `push(chunk)`, `end()`, `toHtml(src)`, `detach()`. `end()` finalises the
50
+ stream: a later `push()` is ignored with a console warning; call `attach(el)` to start a new one. `settle(buffer)`, `promptGuide(options)` and
51
+ `stickToBottom(scroller)` are exported on their own.
52
+
53
+ ## Agent / LLM integration
54
+
55
+ **System prompt (optional).** Skip it and plain markdown still renders fully; add it to unlock callouts, stats and charts. `promptGuide(options?)` returns a short snippet teaching a model the vocabulary
56
+ above. It is generated from the same `directives`/`fences`/`tones` the stream uses, so pass the same
57
+ options: a custom renderer contributes a line by carrying a `.doc` string. The default text ships as
58
+ `references/markdown-stream-prompt.md`.
59
+
60
+ ```js
61
+ import { promptGuide } from '@livx.cc/bare-v3/elements/markdown-stream';
62
+ steps.doc = '`:::steps` … `:::` — a numbered walkthrough';
63
+ const system = promptGuide({ directives: { steps } }) + '\nBe helpful and concise.';
64
+ ```
65
+
66
+ **Server contract.** `POST /api/chat` with `{ messages: [{ role, content }] }` answers `text/plain`
67
+ chunks of the reply: parse the provider's SSE and forward only the text deltas. Abort the upstream
68
+ request when the client disconnects. `scripts/chat-server.ts` is a working example (`bun run chat`;
69
+ OpenRouter or OpenAI via plain HTTP; `CHAT_MODEL`, `CHAT_MAX_TOKENS` and `CHAT_PORT` override).
70
+
71
+ **Client loop.**
72
+
73
+ ```js
74
+ const history = [];
75
+ async function ask(text, signal) {
76
+ history.push({ role: 'user', content: text });
77
+ const view = new MarkdownStream({ Marked, sanitize: (html) => DOMPurify.sanitize(html) }).attach(newArticle());
78
+ try {
79
+ const res = await fetch('/api/chat', { method: 'POST', signal, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ messages: history }) });
80
+ if (!res.ok) throw new Error(await res.text());
81
+ for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) { view.push(chunk); scroll.stick(); }
82
+ } finally { view.end(); history.push({ role: 'assistant', content: view.buf }); }
83
+ }
84
+ ```
85
+
86
+ - **History is raw markdown** (`view.buf`), never rendered HTML; a stopped reply keeps its partial text.
87
+ - **Always sanitize**: model output is untrusted input, and renderers' HTML goes through `sanitize` too.
88
+ - Stop = `AbortController.abort()`; the fetch rejects and the server aborts the provider call.
89
+
90
+ `fixtures/elements/markdown-stream.html` is the full chat (composer, Stop, multi-turn). Under
91
+ `bun run chat` it talks to the model; under any plain static server it falls back to canned replies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/bare-v3",
3
- "version": "0.1.0-alpha.53",
3
+ "version": "0.1.0-alpha.57",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -13,12 +13,14 @@
13
13
  "./references/*": "./dist/references/*",
14
14
  "./ui": "./dist/ui.js",
15
15
  "./elements/image-viewer": "./dist/elements/image-viewer.js",
16
- "./elements/date-picker": "./dist/elements/date-picker.js"
16
+ "./elements/date-picker": "./dist/elements/date-picker.js",
17
+ "./elements/markdown-stream": "./dist/elements/markdown-stream.js"
17
18
  },
18
19
  "scripts": {
19
20
  "build": "bun scripts/build.ts",
20
21
  "test": "bun test",
21
22
  "lab": "bun lab/cli.ts",
23
+ "chat": "bun scripts/chat-server.ts",
22
24
  "check:package": "bun scripts/check-package.ts",
23
25
  "typecheck": "tsc"
24
26
  },
@@ -31,8 +33,10 @@
31
33
  "ai.libx.js": "0.18.29",
32
34
  "basecoat-css": "1.0.2",
33
35
  "daisyui": "5.7.28",
36
+ "happy-dom": "20.14.5",
34
37
  "lightningcss": "1.33.0",
35
38
  "lucide-static": "1.31.0",
39
+ "marked": "18.0.14",
36
40
  "pug": "3.0.4",
37
41
  "tailwindcss": "4.3.3",
38
42
  "typescript": "7.0.2"