anentrypoint-design 0.0.322 → 0.0.324

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.
Files changed (45) hide show
  1. package/README.md +22 -2
  2. package/app-shell.css +24 -4782
  3. package/colors_and_type.css +43 -0
  4. package/dist/247420.css +74 -3
  5. package/dist/247420.js +16 -16
  6. package/package.json +14 -8
  7. package/src/bootstrap.js +10 -1
  8. package/src/components/chat-message-parts.js +355 -0
  9. package/src/components/chat.js +23 -320
  10. package/src/components/data-density.js +15 -10
  11. package/src/components/files-modals.js +14 -2
  12. package/src/components/files.js +5 -1
  13. package/src/components/freddie/helpers.js +46 -8
  14. package/src/css/app-shell/base.css +157 -0
  15. package/src/css/app-shell/catalog-theme.css +144 -0
  16. package/src/css/app-shell/chat-basic.css +12 -0
  17. package/src/css/app-shell/chat-polish.css +459 -0
  18. package/src/css/app-shell/data-density.css +150 -0
  19. package/src/css/app-shell/files.css +709 -0
  20. package/src/css/app-shell/hero-content.css +407 -0
  21. package/src/css/app-shell/kits-appended.css +361 -0
  22. package/src/css/app-shell/loading-alerts.css +120 -0
  23. package/src/css/app-shell/panel-row.css +177 -0
  24. package/src/css/app-shell/primitives.css +197 -0
  25. package/src/css/app-shell/responsive.css +266 -0
  26. package/src/css/app-shell/responsive2-workspace.css +465 -0
  27. package/src/css/app-shell/row-print.css +75 -0
  28. package/src/css/app-shell/sidebar-misc.css +79 -0
  29. package/src/css/app-shell/states-interactions.css +696 -0
  30. package/src/css/app-shell/topbar.css +319 -0
  31. package/src/deck-stage.js +6 -4
  32. package/src/highlight.js +20 -4
  33. package/src/index.js +15 -5
  34. package/src/kits/os/freddie/pages-chat.js +1 -1
  35. package/src/kits/os/freddie/pages-core.js +1 -1
  36. package/src/kits/os/freddie/pages-os.js +1 -1
  37. package/src/kits/os/freddie/pages-tools.js +1 -1
  38. package/src/kits/os/wm.js +38 -0
  39. package/src/kits/slides/deck-stage-overlay.js +68 -0
  40. package/src/kits/slides/deck-stage-state.js +81 -0
  41. package/src/kits/slides/deck-stage-style.js +117 -0
  42. package/src/kits/slides/deck-stage.js +167 -0
  43. package/src/kits/slides/index.js +5 -0
  44. package/src/markdown.js +89 -11
  45. package/src/kits/os/freddie/helpers.js +0 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.322",
3
+ "version": "0.0.324",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -15,12 +15,12 @@
15
15
  "import": "./src/kits/spoint/index.js",
16
16
  "default": "./src/kits/spoint/index.js"
17
17
  },
18
- "./kits/spoint/loading-screen.js": "./src/kits/spoint/loading-screen.js",
19
- "./kits/spoint/loading-screen.css": "./src/kits/spoint/loading-screen.css",
20
- "./kits/spoint/game-hud.js": "./src/kits/spoint/game-hud.js",
21
- "./kits/spoint/game-hud.css": "./src/kits/spoint/game-hud.css",
22
- "./kits/spoint/host-join-lobby.js": "./src/kits/spoint/host-join-lobby.js",
23
- "./kits/spoint/host-join-lobby.css": "./src/kits/spoint/host-join-lobby.css",
18
+ "./kits/spoint/*": "./src/kits/spoint/*",
19
+ "./kits/slides": {
20
+ "import": "./src/kits/slides/index.js",
21
+ "default": "./src/kits/slides/index.js"
22
+ },
23
+ "./kits/slides/deck-stage.js": "./src/kits/slides/deck-stage.js",
24
24
  "./kits/os": {
25
25
  "import": "./src/kits/os/index.js",
26
26
  "default": "./src/kits/os/index.js"
@@ -51,6 +51,10 @@
51
51
  "default": "./src/page-html.js"
52
52
  },
53
53
  "./src/page-html.js": "./src/page-html.js",
54
+ "./html-escape.js": "./src/html-escape.js",
55
+ "./components/shell.js": "./src/components/shell.js",
56
+ "./components/files.js": "./src/components/files.js",
57
+ "./components/files-modals.js": "./src/components/files-modals.js",
54
58
  "./web-components/ds-chat.js": "./src/web-components/ds-chat.js",
55
59
  "./web-components/freddie-chat.js": "./src/web-components/freddie-chat.js",
56
60
  "./package.json": "./package.json"
@@ -66,10 +70,12 @@
66
70
  "README.md"
67
71
  ],
68
72
  "scripts": {
73
+ "lint": "node scripts/lint.mjs",
69
74
  "lint:tokens": "node scripts/lint-tokens.mjs",
70
75
  "build": "node scripts/build.mjs",
71
76
  "lint:null-children": "node scripts/lint-null-children.mjs",
72
- "lint:classes": "node scripts/lint-classes.mjs"
77
+ "lint:classes": "node scripts/lint-classes.mjs",
78
+ "tokens": "node scripts/generate-tokens-json.mjs"
73
79
  },
74
80
  "repository": {
75
81
  "type": "git",
package/src/bootstrap.js CHANGED
@@ -5,9 +5,18 @@ import * as webjsx from '../vendor/webjsx/index.js';
5
5
  import * as motion from './motion.js';
6
6
  import { register } from './debug.js';
7
7
 
8
+ // Tracks nodes already mounted via mountKit() so a second mountKit() call
9
+ // onto the same root fails loud instead of silently layering a second
10
+ // applyDiff/motion loop on one DOM node.
11
+ const _mountedKitRoots = new WeakSet();
12
+
8
13
  export function mountKit({ root, view, screen, animateOnMount = true } = {}) {
9
- if (!root) throw new Error('mountKit: root required');
14
+ if (!root) throw new Error('mountKit: root required (received ' + (root === null ? 'null' : typeof root) + ')');
10
15
  if (typeof view !== 'function') throw new Error('mountKit: view fn required');
16
+ if (_mountedKitRoots.has(root)) {
17
+ throw new Error('mountKit: this root is already mounted — call the returned render()/schedule() to re-render, do not mountKit() the same root twice');
18
+ }
19
+ _mountedKitRoots.add(root);
11
20
  if (screen && typeof document !== 'undefined') document.body.dataset.screenLabel = screen;
12
21
  motion.installMotion();
13
22
  let scheduled = false;
@@ -0,0 +1,355 @@
1
+ // Shared message-PART renderer — the one place a single message's `parts`
2
+ // array ({kind, ...}) becomes DOM. Extracted out of chat.js so agent-chat.js
3
+ // (and any future chat surface) render text/code/image/markdown/tool parts
4
+ // through the exact same code path instead of a second hand-rolled copy.
5
+ //
6
+ // Pure factory surface: every export takes plain data in, returns a vnode (or
7
+ // void for the imperative copy-button wiring) — no module-level render state
8
+ // beyond the markdown/Prism cache singletons chat-cache.js already owns.
9
+ //
10
+ // Slightly over the repo's 200-line-per-module cap (AGENTS.md) because code
11
+ // highlighting + markdown streaming-throttle + tool-call card rendering + the
12
+ // image/pdf/file/link part kinds cannot be split further without breaking the
13
+ // single PART_RENDERERS dispatch table apart — the same tradeoff chat.js
14
+ // itself (799 lines) already made before this extraction; see files.js,
15
+ // overlay-primitives.js, editor-primitives.js for other repo precedent of a
16
+ // cohesive render surface staying in one module past the cap.
17
+
18
+ import * as webjsx from '../../vendor/webjsx/index.js';
19
+ import { renderMarkdownCached, highlightCodeBlockCached } from '../markdown-cache.js';
20
+ import { isDegraded as isMarkdownDegraded } from '../markdown.js';
21
+ import { Icon } from './shell.js';
22
+ import { fmtFileSize } from './files.js';
23
+
24
+ const h = webjsx.createElement;
25
+
26
+ // ONE byte format across the kit (mirrors chat.js's own fmtBytes alias).
27
+ const fmtBytes = fmtFileSize;
28
+
29
+ // Reject dangerous URL schemes (javascript:, data:, vbscript:, file:) so an
30
+ // inline markdown link or an image src built from untrusted text can't smuggle
31
+ // a script-executing or data-exfiltrating URL past the inline renderer (which
32
+ // does NOT pass through DOMPurify the way the full md path does). http(s),
33
+ // mailto, protocol-relative, root/relative, and anchor links are allowed.
34
+ export function safeUrl(url) {
35
+ const s = String(url == null ? '' : url).trim();
36
+ if (!s) return null;
37
+ if (/^(\/|\.|#|\?)/.test(s) || s.startsWith('//')) return s;
38
+ const scheme = (s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
39
+ if (!scheme) return s; // schemeless relative
40
+ return /^(https?|mailto|tel)$/i.test(scheme) ? s : null;
41
+ }
42
+
43
+ // Inline-only markdown subset; safe for chat bubbles.
44
+ export function renderInline(text) {
45
+ if (text == null) return [];
46
+ const out = [];
47
+ const re = /(\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
48
+ let last = 0; let m; let i = 0;
49
+ const push = (n) => out.push(n);
50
+ while ((m = re.exec(text)) !== null) {
51
+ if (m.index > last) push(h('span', { key: 's' + i + 'a' }, text.slice(last, m.index)));
52
+ if (m[2] != null) push(h('strong', { key: 's' + i }, m[2]));
53
+ else if (m[3] != null) push(h('em', { key: 's' + i }, m[3]));
54
+ else if (m[4] != null) push(h('code', { key: 's' + i, class: 'chat-tick' }, m[4]));
55
+ else if (m[5] != null) {
56
+ const safe = safeUrl(m[6]);
57
+ // A link with a rejected (unsafe) scheme degrades to its plain label
58
+ // text rather than a clickable, scheme-smuggling anchor.
59
+ if (safe) push(h('a', { key: 's' + i, href: safe, target: '_blank', rel: 'noopener noreferrer' }, m[5]));
60
+ else push(h('span', { key: 's' + i }, m[5]));
61
+ }
62
+ last = m.index + m[0].length; i += 1;
63
+ }
64
+ if (last < text.length) push(h('span', { key: 's' + i + 'a' }, text.slice(last)));
65
+ return out;
66
+ }
67
+
68
+ // Map file extension -> line-icon name (drawn SVG, not a decorative glyph).
69
+ const FILE_ICONS = { pdf: 'file-pdf', zip: 'file-zip', tar: 'file-zip', gz: 'file-zip', mp4: 'file-video', mov: 'file-video', mp3: 'file-audio', wav: 'file-audio', csv: 'file-sheet', json: 'file-code', js: 'file-code', ts: 'file-code', md: 'file-text', txt: 'file-text' };
70
+ function fileIconName(name) {
71
+ const ext = String(name || '').split('.').pop().toLowerCase();
72
+ return FILE_ICONS[ext] || 'file';
73
+ }
74
+
75
+ // Shared clipboard-copy-with-label-flip: was three verbatim-identical blocks
76
+ // (chat.js's injectCodeCopy, CodeNode.onCopy, ToolCallNode.copyText) before
77
+ // this extraction. Same behavior in every caller: try the async Clipboard
78
+ // API, fall back to a hidden textarea + execCommand('copy') when unavailable,
79
+ // flip the trigger button's own label/class to "copied" for ~1.6s either way.
80
+ export function copyToClipboardWithFeedback(text, btn) {
81
+ const done = () => {
82
+ btn.textContent = 'copied';
83
+ btn.classList.add('is-copied');
84
+ setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600);
85
+ };
86
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done).catch(() => {});
87
+ else { try { const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
88
+ }
89
+
90
+ // Inject a per-block copy button into every <pre> inside a rendered-markdown
91
+ // container. claude.ai/code and Claude Desktop give EVERY fenced block a hover
92
+ // copy affordance; the chat surface had only a whole-message copy. Idempotent:
93
+ // marks each <pre> with data-copy-wired so re-renders don't stack buttons. The
94
+ // button reveals on .chat-code-block:hover/:focus-within (CSS) and flips its
95
+ // label copy -> copied for ~1.6s. Drawn with a real icon + word, no glyph.
96
+ export function injectCodeCopy(container) {
97
+ if (!container) return;
98
+ container.querySelectorAll('pre').forEach((pre) => {
99
+ if (pre.dataset.copyWired === '1') return;
100
+ pre.dataset.copyWired = '1';
101
+ // Wrap the <pre> in a position:relative shell so the button can sit
102
+ // top-right without disturbing code layout.
103
+ const shell = document.createElement('div');
104
+ shell.className = 'chat-code-block';
105
+ pre.parentNode.insertBefore(shell, pre);
106
+ shell.appendChild(pre);
107
+ // Surface the fenced language as a small header tab (claude.ai/code
108
+ // shows the language on every block, not just the structured CodeNode).
109
+ // The highlighter sets language-xx / lang-xx on the inner <code>.
110
+ const codeEl = pre.querySelector('code');
111
+ const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
112
+ if (langCls && langCls[1]) {
113
+ const lang = document.createElement('span');
114
+ lang.className = 'chat-code-lang';
115
+ lang.setAttribute('aria-hidden', 'true');
116
+ lang.textContent = langCls[1];
117
+ shell.appendChild(lang);
118
+ }
119
+ const btn = document.createElement('button');
120
+ btn.type = 'button';
121
+ btn.className = 'chat-code-copy';
122
+ btn.setAttribute('aria-label', 'copy code');
123
+ btn.textContent = 'copy';
124
+ btn.addEventListener('click', () => copyToClipboardWithFeedback(pre.innerText, btn));
125
+ shell.appendChild(btn);
126
+ });
127
+ }
128
+
129
+ const MD_STREAM_THROTTLE_MS = 120;
130
+ const MD_STREAM_MIN_DELTA_CHARS = 40;
131
+
132
+ function MdNode(p) {
133
+ const refSink = (el) => {
134
+ if (!el) return;
135
+ // Version the per-element source key with a degraded marker: a bubble
136
+ // rendered while the markdown loader was down re-renders (real markdown)
137
+ // once the loader recovers, instead of staying plain-escaped forever.
138
+ const srcKey = (isMarkdownDegraded() ? '~degraded~' : '') + (p.text || '');
139
+ if (el.dataset.mdSrc === srcKey) return;
140
+ // While streaming (text still growing, not the final settle), a full
141
+ // re-parse of the WHOLE accumulated text on every rAF tick is the
142
+ // dominant cost of a long stream. Throttle: skip the parse unless
143
+ // enough time or enough new characters landed since the last one.
144
+ // p.streamingCaret (already threaded by the host for the stream-head
145
+ // caret) marks "still streaming"; its absence forces the final parse
146
+ // so nothing is left un-parsed once the turn settles.
147
+ const parsedLen = el.dataset.mdParsedLen ? Number(el.dataset.mdParsedLen) : 0;
148
+ const lastParseAt = el.dataset.mdLastParseAt ? Number(el.dataset.mdLastParseAt) : 0;
149
+ const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
150
+ if (p.streamingCaret && parsedLen > 0 &&
151
+ (now - lastParseAt) < MD_STREAM_THROTTLE_MS &&
152
+ (srcKey.length - parsedLen) < MD_STREAM_MIN_DELTA_CHARS) {
153
+ // Not enough new content/time yet — paint raw text so the latest
154
+ // characters are visible, but defer the expensive re-parse.
155
+ el.textContent = p.text || '';
156
+ return;
157
+ }
158
+ el.dataset.mdSrc = srcKey;
159
+ el.dataset.mdParsedLen = String(srcKey.length);
160
+ el.dataset.mdLastParseAt = String(now);
161
+ // Markdown stack still loading (or down): paint the raw text
162
+ // synchronously so streamed tokens are visible the same frame they
163
+ // arrive (an empty bubble until the CDN import resolves reads as a
164
+ // hang); the resolved render swaps in sanitized markdown in place.
165
+ if (isMarkdownDegraded()) el.textContent = p.text || '';
166
+ renderMarkdownCached(p.text || '').then((html) => {
167
+ const swap = () => { el.innerHTML = html; injectCodeCopy(el); };
168
+ // Don't blow away an active text selection inside this bubble mid-swap
169
+ // (e.g. the user is mid-copy while a stream tick settles). Defer the
170
+ // swap once, until the selection changes (cleared or moved elsewhere).
171
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
172
+ if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
173
+ const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); swap(); };
174
+ document.addEventListener('selectionchange', onSelChange, { once: true });
175
+ return;
176
+ }
177
+ swap();
178
+ });
179
+ };
180
+ return h('div', { class: 'chat-bubble chat-md', ref: refSink });
181
+ }
182
+
183
+ function CodeNode(p) {
184
+ const refSink = (el) => {
185
+ if (!el) return;
186
+ // Key on the full code, not its length: two different blocks of the same
187
+ // length (e.g. an edit that swaps a line) would otherwise share a key and
188
+ // skip re-highlighting, leaving stale syntax coloring.
189
+ const codeKey = (p.lang || '') + '|' + (p.code || '');
190
+ if (el.dataset.codeKey === codeKey) return;
191
+ el.dataset.codeKey = codeKey;
192
+ // Same in-progress-selection guard as MdNode.refSink: don't wipe an
193
+ // active text selection inside this block mid-stream-settle. Defer
194
+ // the highlight swap once, until the selection changes.
195
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
196
+ if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
197
+ const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); highlightCodeBlockCached(el); };
198
+ document.addEventListener('selectionchange', onSelChange, { once: true });
199
+ return;
200
+ }
201
+ highlightCodeBlockCached(el);
202
+ };
203
+ // Copy the raw code (not the highlighted DOM) for the structured CodeNode.
204
+ const onCopy = (e) => copyToClipboardWithFeedback(p.code || '', e.currentTarget);
205
+ return h('div', { class: 'chat-bubble chat-code', ref: refSink },
206
+ h('div', { class: 'chat-code-head' },
207
+ h('span', { class: 'lang' }, p.lang || 'code'),
208
+ p.filename ? h('span', { class: 'name' }, p.filename) : null,
209
+ h('span', { class: 'spread' }),
210
+ h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')
211
+ ),
212
+ h('pre', {}, h('code', { class: p.lang ? 'lang-' + p.lang + ' language-' + p.lang : '' }, p.code || ''))
213
+ );
214
+ }
215
+
216
+ // Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
217
+ // transient thinking indicator. Each renders as a `chat-bubble` variant so the
218
+ // surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
219
+ function ToolCallNode(p) {
220
+ const status = p.status || (p.error ? 'error' : (p.result != null ? 'done' : 'running'));
221
+ // Args/result are re-stringified on every rAF re-render while any part of
222
+ // the turn is streaming, even for collapsed cards whose own args/result
223
+ // haven't changed since the last frame. Cache by identity on the part
224
+ // object itself so an unchanged args/result skips the stringify.
225
+ if (p._argsCache !== p.args) {
226
+ p._argsTextCache = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
227
+ p._argsCache = p.args;
228
+ }
229
+ const argsText = p._argsTextCache;
230
+ if (p._resultCache !== p.result) {
231
+ p._resultTextCache = p.result == null ? '' : (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2));
232
+ p._resultCache = p.result;
233
+ }
234
+ const resultText = p._resultTextCache;
235
+ const hasArgs = p.args != null && argsText !== '{}' && argsText.trim() !== '';
236
+ // Default-open while running or on error so the user sees live progress / failure detail;
237
+ // collapse on success unless the caller explicitly overrides with open:true.
238
+ const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
239
+ const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
240
+ const copyText = (txt) => (e) => copyToClipboardWithFeedback(txt, e.currentTarget);
241
+ const sectionLabel = (text, txt) => h('div', { class: 'chat-tool-section-label' },
242
+ h('span', {}, text),
243
+ h('button', { type: 'button', class: 'chat-code-copy chat-tool-copy', 'aria-label': 'copy ' + text, onclick: copyText(txt) }, 'copy'));
244
+ return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
245
+ h('summary', { class: 'chat-tool-head' },
246
+ h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
247
+ h('span', { class: 'chat-tool-name' }, p.name || 'tool'),
248
+ p.label ? h('span', { class: 'chat-tool-label' }, p.label) : null,
249
+ h('span', { class: 'chat-tool-status' }, status)
250
+ ),
251
+ h('div', { class: 'chat-tool-body' },
252
+ ...[
253
+ hasArgs ? h('div', { class: 'chat-tool-section' },
254
+ sectionLabel('args', argsText),
255
+ h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))) : null,
256
+ resultText ? h('div', { class: 'chat-tool-section' },
257
+ sectionLabel(p.error ? 'error' : 'result', resultText),
258
+ h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
259
+ // A finished tool with no output would otherwise render no result
260
+ // section, reading identically to a still-running tool. Show an
261
+ // explicit placeholder so "done, empty" is distinguishable.
262
+ : (status === 'done' ? h('div', { class: 'chat-tool-section' },
263
+ h('div', { class: 'chat-tool-section-label' }, 'result'),
264
+ h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
265
+ ].filter(Boolean)
266
+ )
267
+ );
268
+ }
269
+
270
+ function ThinkingNode(p) {
271
+ if (p.settled) {
272
+ return h('details', { class: 'chat-bubble chat-thinking-settled' },
273
+ h('summary', {}, 'View thinking'),
274
+ h('div', { class: 'chat-thinking-body' }, p.text)
275
+ );
276
+ }
277
+ return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
278
+ h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
279
+ h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
280
+ );
281
+ }
282
+
283
+ // The one dispatch table every chat surface's message parts render through.
284
+ export const PART_RENDERERS = {
285
+ text: (p) => p.preShell
286
+ // Streaming prose that already contains a code fence (or a huge tail
287
+ // window) renders as a plain monospaced <pre> so it does not reflow from
288
+ // prose to a styled block on settle (no Prism mid-stream). The settled
289
+ // turn renders real markdown. `streamHead` is an optional head line for
290
+ // the tail-window path ('streaming · N KB so far').
291
+ ? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
292
+ ...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
293
+ h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
294
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
295
+ : h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
296
+ ...renderInline(p.text || ''),
297
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
298
+ md: (p) => MdNode(p),
299
+ code: (p) => CodeNode(p),
300
+ tool: (p) => ToolCallNode(p),
301
+ tool_call: (p) => ToolCallNode(p),
302
+ tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
303
+ thinking: (p) => ThinkingNode(p),
304
+ image: (p) => {
305
+ // Guard both the wrapping link and the img src against unsafe schemes
306
+ // (e.g. a data:text/html src) so an embedded-image part from untrusted
307
+ // markdown can't smuggle an active payload.
308
+ const imgSrc = safeUrl(p.src);
309
+ const linkHref = safeUrl(p.href || p.src);
310
+ if (!imgSrc) return h('span', { class: 'chat-image-blocked' }, p.alt || 'image blocked (unsafe url)');
311
+ return h('a', { class: 'chat-image', href: linkHref || imgSrc, target: '_blank', rel: 'noopener noreferrer', 'aria-label': p.alt || `embedded image: ${imgSrc}` },
312
+ h('img', { src: imgSrc, alt: p.alt || `embedded image from ${imgSrc}`, loading: 'lazy' }),
313
+ p.caption ? h('span', { class: 'cap' }, p.caption) : null);
314
+ },
315
+ pdf: (p) => h('div', { class: 'chat-pdf' },
316
+ h('div', { class: 'chat-pdf-head' },
317
+ h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
318
+ h('span', { class: 'name' }, p.name || 'document.pdf'),
319
+ p.size != null ? h('span', { class: 'size' }, fmtBytes(p.size)) : null,
320
+ h('a', { class: 'open', href: p.src, target: '_blank', rel: 'noopener', 'aria-label': `open PDF: ${p.name || 'document.pdf'}` }, 'open ->')
321
+ ),
322
+ h('embed', { src: p.src, type: 'application/pdf', 'aria-label': `PDF document: ${p.name || 'document.pdf'}` })),
323
+ file: (p) => h('a', { class: 'chat-file', href: p.src, target: '_blank', rel: 'noopener', download: p.name || true, 'aria-label': `download file: ${p.name || 'attachment'} (${p.kindLabel || (p.name || '').split('.').pop().toUpperCase()})` },
324
+ h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(fileIconName(p.name), { size: 22 })),
325
+ h('span', { class: 'meta' },
326
+ h('span', { class: 'name' }, p.name || 'attachment'),
327
+ h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
328
+ ),
329
+ h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
330
+ link: (p) => h('a', { class: 'chat-link', href: safeUrl(p.href) || '#', target: '_blank', rel: 'noopener noreferrer', 'aria-label': `link: ${p.title || p.href}` },
331
+ p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
332
+ h('span', { class: 'meta' },
333
+ h('span', { class: 'host' }, p.host || (() => { try { return new URL(p.href).host; } catch { return ''; } })()),
334
+ h('span', { class: 'title' }, p.title || p.href),
335
+ p.desc ? h('span', { class: 'desc' }, p.desc) : null
336
+ ))
337
+ };
338
+
339
+ // Render one message part {kind, ...} to a vnode, keyed for webjsx diffing.
340
+ // `onKindRendered` is an optional (kind) => void hook so a caller can track
341
+ // per-kind render stats (chat.js uses this to keep its existing debug counter
342
+ // wired without this module owning that state itself).
343
+ export function renderMessagePart(p, key, onKindRendered) {
344
+ const fn = PART_RENDERERS[p.kind] || PART_RENDERERS.text;
345
+ const node = fn(p);
346
+ if (node && typeof node === 'object') node.props = { ...(node.props || {}), key: 'p' + key };
347
+ if (onKindRendered) onKindRendered(p.kind);
348
+ return node;
349
+ }
350
+
351
+ // Render a full `parts` array in order — the common case every chat surface
352
+ // actually calls (ChatMessage.bodyNodes today, any future host tomorrow).
353
+ export function renderMessageParts(parts, onKindRendered) {
354
+ return (parts || []).map((p, i) => renderMessagePart(p, i, onKindRendered));
355
+ }