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
@@ -3,12 +3,12 @@
3
3
  // Includes ChatMessage, ChatComposer, Chat, AICat, AICatPortrait.
4
4
 
5
5
  import * as webjsx from '../../vendor/webjsx/index.js';
6
- import { renderMarkdownCached, highlightCodeBlockCached, initializeCachesEagerly, getCacheStats } from '../markdown-cache.js';
7
- import { isDegraded as isMarkdownDegraded } from '../markdown.js';
6
+ import { initializeCachesEagerly, getCacheStats } from '../markdown-cache.js';
8
7
  import { register } from '../debug.js';
9
8
  import { Icon } from './shell.js';
10
9
  import { fmtFileSize } from './files.js';
11
10
  import { EmojiPicker } from './overlay-primitives.js';
11
+ import { renderMessagePart as sharedRenderMessagePart, safeUrl as sharedSafeUrl, renderInline as sharedRenderInline, injectCodeCopy as sharedInjectCodeCopy } from './chat-message-parts.js';
12
12
 
13
13
  // Matches a trailing `:keyword` at the end of the composer draft (optionally
14
14
  // preceded by whitespace/start-of-string) so typing `:smile` opens an inline
@@ -24,52 +24,13 @@ let _cacheInitialized = false;
24
24
  // existing imports working while rendering the same string as the Files grid.
25
25
  export const fmtBytes = fmtFileSize;
26
26
 
27
- // Reject dangerous URL schemes (javascript:, data:, vbscript:, file:) so an
28
- // inline markdown link or an image src built from untrusted text can't smuggle a
29
- // script-executing or data-exfiltrating URL past the inline renderer (which does
30
- // NOT pass through DOMPurify the way the full md path does). http(s), mailto,
31
- // protocol-relative, root/relative, and anchor links are allowed.
32
- export function safeUrl(url) {
33
- const s = String(url == null ? '' : url).trim();
34
- if (!s) return null;
35
- // Allow relative / anchor / protocol-relative without a scheme.
36
- if (/^(\/|\.|#|\?)/.test(s) || s.startsWith('//')) return s;
37
- const scheme = (s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
38
- if (!scheme) return s; // schemeless relative
39
- return /^(https?|mailto|tel)$/i.test(scheme) ? s : null;
40
- }
41
-
42
- // Inline-only markdown subset; safe for chat bubbles.
43
- export function renderInline(text) {
44
- if (text == null) return [];
45
- const out = [];
46
- const re = /(\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
47
- let last = 0; let m; let i = 0;
48
- const push = (n) => out.push(n);
49
- while ((m = re.exec(text)) !== null) {
50
- if (m.index > last) push(h('span', { key: 's' + i + 'a' }, text.slice(last, m.index)));
51
- if (m[2] != null) push(h('strong', { key: 's' + i }, m[2]));
52
- else if (m[3] != null) push(h('em', { key: 's' + i }, m[3]));
53
- else if (m[4] != null) push(h('code', { key: 's' + i, class: 'chat-tick' }, m[4]));
54
- else if (m[5] != null) {
55
- const safe = safeUrl(m[6]);
56
- // A link with a rejected (unsafe) scheme degrades to its plain label
57
- // text rather than a clickable, scheme-smuggling anchor.
58
- if (safe) push(h('a', { key: 's' + i, href: safe, target: '_blank', rel: 'noopener noreferrer' }, m[5]));
59
- else push(h('span', { key: 's' + i }, m[5]));
60
- }
61
- last = m.index + m[0].length; i += 1;
62
- }
63
- if (last < text.length) push(h('span', { key: 's' + i + 'a' }, text.slice(last)));
64
- return out;
65
- }
66
-
67
- // Map file extension -> line-icon name (drawn SVG, not a decorative glyph).
68
- 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' };
69
- function fileIconName(name) {
70
- const ext = String(name || '').split('.').pop().toLowerCase();
71
- return FILE_ICONS[ext] || 'file';
72
- }
27
+ // safeUrl / renderInline now live in chat-message-parts.js (the shared
28
+ // message-part renderer both this file and agent-chat.js render parts
29
+ // through) — re-exported here under their original names so every existing
30
+ // consumer of chat.js's public API (components.js barrel, any host importing
31
+ // directly from './components/chat.js') keeps working unchanged.
32
+ export const safeUrl = sharedSafeUrl;
33
+ export const renderInline = sharedRenderInline;
73
34
 
74
35
  // Eagerly warm the markdown + Prism caches on first chat-surface mount, once.
75
36
  function ensureCachesInit() {
@@ -117,280 +78,22 @@ export function makeThreadAutoScroll(getCount) {
117
78
  };
118
79
  }
119
80
 
120
- // Inject a per-block copy button into every <pre> inside a rendered-markdown
121
- // container. claude.ai/code and Claude Desktop give EVERY fenced block a hover
122
- // copy affordance; the chat surface had only a whole-message copy. Idempotent:
123
- // marks each <pre> with data-copy-wired so re-renders don't stack buttons. The
124
- // button reveals on .chat-code-block:hover/:focus-within (CSS) and flips its
125
- // label copy -> copied for ~1.6s. Drawn with a real icon + word, no glyph.
126
- export function injectCodeCopy(container) {
127
- if (!container) return;
128
- container.querySelectorAll('pre').forEach((pre) => {
129
- if (pre.dataset.copyWired === '1') return;
130
- pre.dataset.copyWired = '1';
131
- // Wrap the <pre> in a position:relative shell so the button can sit
132
- // top-right without disturbing code layout.
133
- const shell = document.createElement('div');
134
- shell.className = 'chat-code-block';
135
- pre.parentNode.insertBefore(shell, pre);
136
- shell.appendChild(pre);
137
- // Surface the fenced language as a small header tab (claude.ai/code
138
- // shows the language on every block, not just the structured CodeNode).
139
- // The highlighter sets language-xx / lang-xx on the inner <code>.
140
- const codeEl = pre.querySelector('code');
141
- const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
142
- if (langCls && langCls[1]) {
143
- const lang = document.createElement('span');
144
- lang.className = 'chat-code-lang';
145
- lang.setAttribute('aria-hidden', 'true');
146
- lang.textContent = langCls[1];
147
- shell.appendChild(lang);
148
- }
149
- const btn = document.createElement('button');
150
- btn.type = 'button';
151
- btn.className = 'chat-code-copy';
152
- btn.setAttribute('aria-label', 'copy code');
153
- btn.textContent = 'copy';
154
- btn.addEventListener('click', () => {
155
- const code = pre.innerText;
156
- const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
157
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
158
- else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
159
- });
160
- shell.appendChild(btn);
161
- });
162
- }
163
-
164
- const MD_STREAM_THROTTLE_MS = 120;
165
- const MD_STREAM_MIN_DELTA_CHARS = 40;
166
-
167
- function MdNode(p) {
168
- const refSink = (el) => {
169
- if (!el) return;
170
- // Version the per-element source key with a degraded marker: a bubble
171
- // rendered while the markdown loader was down re-renders (real markdown)
172
- // once the loader recovers, instead of staying plain-escaped forever.
173
- const srcKey = (isMarkdownDegraded() ? '~degraded~' : '') + (p.text || '');
174
- if (el.dataset.mdSrc === srcKey) return;
175
- // While streaming (text still growing, not the final settle), a full
176
- // re-parse of the WHOLE accumulated text on every rAF tick is the
177
- // dominant cost of a long stream. Throttle: skip the parse unless
178
- // enough time or enough new characters landed since the last one.
179
- // p.streamingCaret (already threaded by the host for the stream-head
180
- // caret) marks "still streaming"; its absence forces the final parse
181
- // so nothing is left un-parsed once the turn settles.
182
- const parsedLen = el.dataset.mdParsedLen ? Number(el.dataset.mdParsedLen) : 0;
183
- const lastParseAt = el.dataset.mdLastParseAt ? Number(el.dataset.mdLastParseAt) : 0;
184
- const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
185
- if (p.streamingCaret && parsedLen > 0 &&
186
- (now - lastParseAt) < MD_STREAM_THROTTLE_MS &&
187
- (srcKey.length - parsedLen) < MD_STREAM_MIN_DELTA_CHARS) {
188
- // Not enough new content/time yet — paint raw text so the latest
189
- // characters are visible, but defer the expensive re-parse.
190
- el.textContent = p.text || '';
191
- return;
192
- }
193
- el.dataset.mdSrc = srcKey;
194
- el.dataset.mdParsedLen = String(srcKey.length);
195
- el.dataset.mdLastParseAt = String(now);
196
- // Markdown stack still loading (or down): paint the raw text
197
- // synchronously so streamed tokens are visible the same frame they
198
- // arrive (an empty bubble until the CDN import resolves reads as a
199
- // hang); the resolved render swaps in sanitized markdown in place.
200
- if (isMarkdownDegraded()) el.textContent = p.text || '';
201
- renderMarkdownCached(p.text || '').then((html) => {
202
- const swap = () => { el.innerHTML = html; injectCodeCopy(el); };
203
- // Don't blow away an active text selection inside this bubble mid-swap
204
- // (e.g. the user is mid-copy while a stream tick settles). Defer the
205
- // swap once, until the selection changes (cleared or moved elsewhere).
206
- const sel = typeof window !== 'undefined' ? window.getSelection() : null;
207
- if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
208
- const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); swap(); };
209
- document.addEventListener('selectionchange', onSelChange, { once: true });
210
- return;
211
- }
212
- swap();
213
- });
214
- };
215
- return h('div', { class: 'chat-bubble chat-md', ref: refSink });
216
- }
217
-
218
- function CodeNode(p) {
219
- const refSink = (el) => {
220
- if (!el) return;
221
- // Key on the full code, not its length: two different blocks of the same
222
- // length (e.g. an edit that swaps a line) would otherwise share a key and
223
- // skip re-highlighting, leaving stale syntax coloring.
224
- const codeKey = (p.lang || '') + '|' + (p.code || '');
225
- if (el.dataset.codeKey === codeKey) return;
226
- el.dataset.codeKey = codeKey;
227
- // Same in-progress-selection guard as MdNode.refSink: don't wipe an
228
- // active text selection inside this block mid-stream-settle. Defer
229
- // the highlight swap once, until the selection changes.
230
- const sel = typeof window !== 'undefined' ? window.getSelection() : null;
231
- if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
232
- const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); highlightCodeBlockCached(el); };
233
- document.addEventListener('selectionchange', onSelChange, { once: true });
234
- return;
235
- }
236
- highlightCodeBlockCached(el);
237
- };
238
- // Copy the raw code (not the highlighted DOM) for the structured CodeNode.
239
- const onCopy = (e) => {
240
- const btn = e.currentTarget;
241
- const code = p.code || '';
242
- const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
243
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
244
- else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
245
- };
246
- return h('div', { class: 'chat-bubble chat-code', ref: refSink },
247
- h('div', { class: 'chat-code-head' },
248
- h('span', { class: 'lang' }, p.lang || 'code'),
249
- p.filename ? h('span', { class: 'name' }, p.filename) : null,
250
- h('span', { class: 'spread' }),
251
- h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')
252
- ),
253
- h('pre', {}, h('code', { class: p.lang ? 'lang-' + p.lang + ' language-' + p.lang : '' }, p.code || ''))
254
- );
255
- }
256
-
257
- // Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
258
- // transient thinking indicator. Each renders as a `chat-bubble` variant so the
259
- // surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
260
- function ToolCallNode(p) {
261
- const status = p.status || (p.error ? 'error' : (p.result != null ? 'done' : 'running'));
262
- // Args/result are re-stringified on every rAF re-render while any part of
263
- // the turn is streaming, even for collapsed cards whose own args/result
264
- // haven't changed since the last frame. Cache by identity on the part
265
- // object itself so an unchanged args/result skips the stringify.
266
- if (p._argsCache !== p.args) {
267
- p._argsTextCache = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
268
- p._argsCache = p.args;
269
- }
270
- const argsText = p._argsTextCache;
271
- if (p._resultCache !== p.result) {
272
- p._resultTextCache = p.result == null ? '' : (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2));
273
- p._resultCache = p.result;
274
- }
275
- const resultText = p._resultTextCache;
276
- const hasArgs = p.args != null && argsText !== '{}' && argsText.trim() !== '';
277
- // Default-open while running or on error so the user sees live progress / failure detail;
278
- // collapse on success unless the caller explicitly overrides with open:true.
279
- const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
280
- const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
281
- const copyText = (txt) => (e) => {
282
- const b = e.currentTarget;
283
- const done = () => {
284
- b.textContent = 'copied';
285
- b.classList.add('is-copied');
286
- setTimeout(() => { b.textContent = 'copy'; b.classList.remove('is-copied'); }, 1600);
287
- };
288
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => {});
289
- else { try { const t = document.createElement('textarea'); t.value = txt; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
290
- };
291
- const sectionLabel = (text, txt) => h('div', { class: 'chat-tool-section-label' },
292
- h('span', {}, text),
293
- h('button', { type: 'button', class: 'chat-code-copy chat-tool-copy', 'aria-label': 'copy ' + text, onclick: copyText(txt) }, 'copy'));
294
- return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
295
- h('summary', { class: 'chat-tool-head' },
296
- h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
297
- h('span', { class: 'chat-tool-name' }, p.name || 'tool'),
298
- p.label ? h('span', { class: 'chat-tool-label' }, p.label) : null,
299
- h('span', { class: 'chat-tool-status' }, status)
300
- ),
301
- h('div', { class: 'chat-tool-body' },
302
- ...[
303
- hasArgs ? h('div', { class: 'chat-tool-section' },
304
- sectionLabel('args', argsText),
305
- h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))) : null,
306
- resultText ? h('div', { class: 'chat-tool-section' },
307
- sectionLabel(p.error ? 'error' : 'result', resultText),
308
- h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
309
- // A finished tool with no output would otherwise render no result
310
- // section, reading identically to a still-running tool. Show an
311
- // explicit placeholder so "done, empty" is distinguishable.
312
- : (status === 'done' ? h('div', { class: 'chat-tool-section' },
313
- h('div', { class: 'chat-tool-section-label' }, 'result'),
314
- h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
315
- ].filter(Boolean)
316
- )
317
- );
318
- }
319
-
320
- function ThinkingNode(p) {
321
- if (p.settled) {
322
- return h('details', { class: 'chat-bubble chat-thinking-settled' },
323
- h('summary', {}, 'View thinking'),
324
- h('div', { class: 'chat-thinking-body' }, p.text)
325
- );
326
- }
327
- return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
328
- h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
329
- h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
330
- );
331
- }
332
-
333
- const PART_RENDERERS = {
334
- text: (p) => p.preShell
335
- // Streaming prose that already contains a code fence (or a huge tail
336
- // window) renders as a plain monospaced <pre> so it does not reflow from
337
- // prose to a styled block on settle (no Prism mid-stream). The settled
338
- // turn renders real markdown. `streamHead` is an optional head line for
339
- // the tail-window path ('streaming · N KB so far').
340
- ? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
341
- ...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
342
- h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
343
- p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
344
- : h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
345
- ...renderInline(p.text || ''),
346
- p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
347
- md: (p) => MdNode(p),
348
- code: (p) => CodeNode(p),
349
- tool: (p) => ToolCallNode(p),
350
- tool_call: (p) => ToolCallNode(p),
351
- tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
352
- thinking: (p) => ThinkingNode(p),
353
- image: (p) => {
354
- // Guard both the wrapping link and the img src against unsafe schemes
355
- // (e.g. a data:text/html src) so an embedded-image part from untrusted
356
- // markdown can't smuggle an active payload.
357
- const imgSrc = safeUrl(p.src);
358
- const linkHref = safeUrl(p.href || p.src);
359
- if (!imgSrc) return h('span', { class: 'chat-image-blocked' }, p.alt || 'image blocked (unsafe url)');
360
- return h('a', { class: 'chat-image', href: linkHref || imgSrc, target: '_blank', rel: 'noopener noreferrer', 'aria-label': p.alt || `embedded image: ${imgSrc}` },
361
- h('img', { src: imgSrc, alt: p.alt || `embedded image from ${imgSrc}`, loading: 'lazy' }),
362
- p.caption ? h('span', { class: 'cap' }, p.caption) : null);
363
- },
364
- pdf: (p) => h('div', { class: 'chat-pdf' },
365
- h('div', { class: 'chat-pdf-head' },
366
- h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
367
- h('span', { class: 'name' }, p.name || 'document.pdf'),
368
- p.size != null ? h('span', { class: 'size' }, fmtBytes(p.size)) : null,
369
- h('a', { class: 'open', href: p.src, target: '_blank', rel: 'noopener', 'aria-label': `open PDF: ${p.name || 'document.pdf'}` }, 'open ->')
370
- ),
371
- h('embed', { src: p.src, type: 'application/pdf', 'aria-label': `PDF document: ${p.name || 'document.pdf'}` })),
372
- 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()})` },
373
- h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(fileIconName(p.name), { size: 22 })),
374
- h('span', { class: 'meta' },
375
- h('span', { class: 'name' }, p.name || 'attachment'),
376
- h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
377
- ),
378
- h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
379
- link: (p) => h('a', { class: 'chat-link', href: safeUrl(p.href) || '#', target: '_blank', rel: 'noopener noreferrer', 'aria-label': `link: ${p.title || p.href}` },
380
- p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
381
- h('span', { class: 'meta' },
382
- h('span', { class: 'host' }, p.host || (() => { try { return new URL(p.href).host; } catch { return ''; } })()),
383
- h('span', { class: 'title' }, p.title || p.href),
384
- p.desc ? h('span', { class: 'desc' }, p.desc) : null
385
- ))
386
- };
81
+ // injectCodeCopy / MdNode / CodeNode / ToolCallNode / ThinkingNode /
82
+ // PART_RENDERERS all now live in chat-message-parts.js — the one dispatch
83
+ // table this file, agent-chat.js, and any future chat surface render message
84
+ // parts through. injectCodeCopy stays exported here (re-exported, same
85
+ // signature) since it was part of this file's public surface before the
86
+ // extraction, even though nothing in-repo imports it directly today.
87
+ export const injectCodeCopy = sharedInjectCodeCopy;
387
88
 
89
+ // Thin wrapper around the shared renderer that keeps this file's own debug
90
+ // counter (_stats.lastKindCounts, surfaced via register('chat', ...) below)
91
+ // wired exactly as before. sharedRenderMessagePart already applies the
92
+ // 'p' + key VElement key itself.
388
93
  function renderPart(p, key) {
389
- const fn = PART_RENDERERS[p.kind] || PART_RENDERERS.text;
390
- const node = fn(p);
391
- if (node && typeof node === 'object') node.props = { ...(node.props || {}), key: 'p' + key };
392
- _stats.lastKindCounts[p.kind] = (_stats.lastKindCounts[p.kind] || 0) + 1;
393
- return node;
94
+ return sharedRenderMessagePart(p, key, (kind) => {
95
+ _stats.lastKindCounts[kind] = (_stats.lastKindCounts[kind] || 0) + 1;
96
+ });
394
97
  }
395
98
 
396
99
  export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped, flat }) {
@@ -60,11 +60,14 @@ export function TreeNode({ ts, kind, variant = '', phase, id, keyLabel, reason,
60
60
  // ---------------------------------------------------------------------------
61
61
  export function BarRow({ label, value, pct = 0, tone } = {}) {
62
62
  const clamped = Math.max(0, Math.min(100, pct));
63
- return h('div', { class: 'ds-bar-row' },
63
+ return h('div', {
64
+ class: 'ds-bar-row', role: 'meter', 'aria-label': label != null ? String(label) : 'value',
65
+ 'aria-valuenow': String(clamped), 'aria-valuemin': '0', 'aria-valuemax': '100',
66
+ },
64
67
  h('span', { class: 'ds-bar-row-label', style: tone ? `color:${tone}` : null }, label),
65
- h('div', { class: 'ds-bar-bg' },
68
+ h('div', { class: 'ds-bar-bg', 'aria-hidden': 'true' },
66
69
  h('div', { class: 'ds-bar-fill', style: `width:${clamped}%` + (tone ? `;background:${tone}` : '') })),
67
- h('span', { class: 'ds-bar-row-value' }, value));
70
+ h('span', { class: 'ds-bar-row-value', 'aria-hidden': 'true' }, value));
68
71
  }
69
72
 
70
73
  // ---------------------------------------------------------------------------
@@ -72,15 +75,15 @@ export function BarRow({ label, value, pct = 0, tone } = {}) {
72
75
  // cls on StatTile selects an accent variant: '' | 'rate-big' | 'err-rate'.
73
76
  // ---------------------------------------------------------------------------
74
77
  export function StatTile({ val, lbl, cls = '' } = {}) {
75
- return h('div', { class: 'ds-stat' },
76
- h('div', { class: 'ds-stat-val' + (cls ? ' ' + cls : '') }, val),
77
- h('div', { class: 'ds-stat-lbl' }, lbl));
78
+ return h('div', { class: 'ds-stat', role: 'group', 'aria-label': `${lbl || 'stat'}: ${val}` },
79
+ h('div', { class: 'ds-stat-val' + (cls ? ' ' + cls : ''), 'aria-hidden': 'true' }, val),
80
+ h('div', { class: 'ds-stat-lbl', 'aria-hidden': 'true' }, lbl));
78
81
  }
79
82
 
80
83
  export function StatsGrid({ items = [] } = {}) {
81
84
  if (!items.length) return h('div', { class: 'ds-stats-grid ds-stats-grid-empty' },
82
85
  h('span', { class: 'ds-stat-lbl' }, 'no stats'));
83
- return h('div', { class: 'ds-stats-grid' },
86
+ return h('div', { class: 'ds-stats-grid', role: 'group', 'aria-label': 'stats' },
84
87
  ...items.map((it, i) => h('div', { key: it.key || i }, StatTile(it))));
85
88
  }
86
89
 
@@ -90,11 +93,12 @@ export function StatsGrid({ items = [] } = {}) {
90
93
  export function SubGrid({ items = [] } = {}) {
91
94
  if (!items.length) return h('div', { class: 'ds-sub-grid ds-sub-grid-empty' },
92
95
  h('span', { class: 'ds-stat-lbl' }, 'no items'));
93
- return h('div', { class: 'ds-sub-grid' },
96
+ return h('div', { class: 'ds-sub-grid', role: 'group', 'aria-label': 'categories' },
94
97
  ...items.map((it, i) => h('button', {
95
98
  key: it.key || i, type: 'button', class: 'ds-sub-btn',
96
99
  onclick: it.onClick || null,
97
- }, h('span', {}, String(it.count)), it.label)));
100
+ 'aria-label': `${it.label}: ${String(it.count)}`,
101
+ }, h('span', { 'aria-hidden': 'true' }, String(it.count)), it.label)));
98
102
  }
99
103
 
100
104
  // ---------------------------------------------------------------------------
@@ -115,6 +119,7 @@ export function SessionRow({ sessId, phaseWalkProps, events, verbs, prd, muts, r
115
119
  class: 'ds-session-row', onclick: onClick || null,
116
120
  role: onClick ? 'button' : null, tabindex: onClick ? '0' : null,
117
121
  onkeydown: onClick ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onClick(e); } } : null,
122
+ 'aria-label': onClick ? `session ${sessId}: ${counts}` : null,
118
123
  },
119
124
  h('span', { class: 'ds-session-row-id' }, sessId),
120
125
  h('span', { class: 'ds-session-row-counts' }, counts),
@@ -163,6 +168,6 @@ export function LiveLog({ entries = [], autoScroll = true } = {}) {
163
168
  };
164
169
  if (!entries.length) return h('div', { class: 'ds-live-log ds-live-log-empty' },
165
170
  h('span', { class: 'ds-stat-lbl' }, 'no log entries'));
166
- return h('div', { class: 'ds-live-log', ref: seedScroll },
171
+ return h('div', { class: 'ds-live-log', ref: seedScroll, role: 'log', 'aria-label': 'live log' },
167
172
  ...entries.map((e, i) => h('div', { key: e.key || i }, LiveLogEntry(e))));
168
173
  }
@@ -178,7 +178,19 @@ export function ConfirmDialog({ title = 'Are you sure?', message, confirmLabel =
178
178
  });
179
179
  }
180
180
 
181
- export function PromptDialog({ title = 'Enter a name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…' } = {}) {
181
+ export function PromptDialog({ title = 'Enter a name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…', roots, onPickRoot } = {}) {
182
+ // Optional one-click starting-point chips (e.g. a destination-path prompt
183
+ // for a filesystem with more than one allowed root) - a user typing a
184
+ // path has no way to discover what a second disjoint root even looks
185
+ // like otherwise. Each { path, label } fills the input via the same
186
+ // onInput callback a manual keystroke would.
187
+ const rootsRow = (roots && roots.length)
188
+ ? h('div', { class: 'ds-prompt-roots', role: 'group', 'aria-label': 'accessible folders' },
189
+ ...roots.map((r, i) => h('button', {
190
+ key: 'pr' + i, type: 'button', class: 'ds-prompt-root-chip',
191
+ onclick: () => { const p = r.path || r; if (onPickRoot) onPickRoot(p); else if (onInput) onInput(p); },
192
+ }, r.label || r.path || r)))
193
+ : null;
182
194
  return Modal({
183
195
  onClose: onCancel,
184
196
  kind: 'small',
@@ -198,7 +210,7 @@ export function PromptDialog({ title = 'Enter a name', value = '', placeholder =
198
210
  if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); if (!busy) onConfirm && onConfirm(e.target.value); }
199
211
  if (e.key === 'Escape') { e.preventDefault(); if (!busy) onCancel && onCancel(); }
200
212
  }
201
- }), modalError(error)].filter(Boolean),
213
+ }), rootsRow, modalError(error)].filter(Boolean),
202
214
  actions: [
203
215
  Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
204
216
  Btn({
@@ -28,9 +28,13 @@ export function fileGlyph(type) {
28
28
  }
29
29
 
30
30
  // The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
31
- // format everywhere: '0 B' for zero; the em-dash means unknown/null ONLY.
31
+ // format everywhere: '0 B' for zero; the em-dash means unknown/null/invalid
32
+ // (NaN, a negative count, or anything non-numeric never reaches the divide
33
+ // loop — previously NaN fell through to the loop unchanged and rendered the
34
+ // literal string "NaN B").
32
35
  export function fmtFileSize(bytes) {
33
36
  if (bytes == null) return '—';
37
+ if (typeof bytes !== 'number' || Number.isNaN(bytes) || bytes < 0) return '—';
34
38
  if (bytes === 0) return '0 B';
35
39
  const u = ['B', 'KB', 'MB', 'GB', 'TB'];
36
40
  let i = 0, n = bytes;
@@ -1,6 +1,11 @@
1
- // Freddie helpers — minimal stubs. Real freddie surfaces ship downstream
2
- // (gm-cc, foph, hermes-fork). The SDK exposes the registry shape and
3
- // localStorage helpers so consumer pages can wire to existing patterns.
1
+ // Freddie helpers — shared registry/localStorage/render helpers used by both
2
+ // the freddie component surface (src/components/freddie.js) and the OS kit's
3
+ // freddie pages (src/kits/os/freddie/*.js). Canonical merged location: this
4
+ // module previously had a diverged twin at src/kits/os/freddie/helpers.js
5
+ // (pre/form/getRecentPaths/saveRecentPath/skillLabel) that re-implemented the
6
+ // same localStorage-recent-paths + skill-label concerns with different keys
7
+ // and a different skillLabel() call shape; merged here so both surfaces
8
+ // share one behavior.
4
9
 
5
10
  import * as webjsx from '../../../vendor/webjsx/index.js';
6
11
  const h = webjsx.createElement;
@@ -13,7 +18,22 @@ export function renderPageStub({ id, title }) {
13
18
  );
14
19
  }
15
20
 
16
- const SKILL_LABELS = {
21
+ // pre() / form() — small raw-DOM helpers used by the OS kit's freddie pages
22
+ // (JSON dumps, quick add-forms) that don't need the full component barrel.
23
+ export function pre(obj) {
24
+ return h('pre', { class: 'fd-pre' }, typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
25
+ }
26
+
27
+ export function form(opts) {
28
+ const { fields = [], submit = 'submit', onSubmit } = opts;
29
+ return h('form', { class: 'row-form', onsubmit: (ev) => { ev.preventDefault(); onSubmit && onSubmit(ev); } },
30
+ ...fields.map(f => f.kind === 'textarea'
31
+ ? h('textarea', { name: f.name, placeholder: f.placeholder || '', rows: f.rows || 4 })
32
+ : h('input', { name: f.name, type: f.type || 'text', placeholder: f.placeholder || '', value: f.value || '', required: f.required ? 'true' : null })),
33
+ h('button', { type: 'submit', class: 'btn-primary' }, submit));
34
+ }
35
+
36
+ const SKILL_SLUG_LABELS = {
17
37
  transcribe: 'transcribe',
18
38
  summarize: 'summarize',
19
39
  translate: 'translate',
@@ -21,11 +41,29 @@ const SKILL_LABELS = {
21
41
  classify: 'classify',
22
42
  };
23
43
 
24
- export function skillLabel(slug) {
25
- return SKILL_LABELS[slug] || slug;
44
+ // skillLabel() accepts EITHER call shape used by existing call sites:
45
+ // skillLabel('transcribe') -> looks up the static slug map
46
+ // skillLabel({ name, shortName }) -> derives a display label from a
47
+ // freddie skill object (OS kit's
48
+ // pages-core/-tools/-chat usage)
49
+ // Additive/widened on purpose: never narrows either existing signature.
50
+ export function skillLabel(input) {
51
+ if (input && typeof input === 'object') {
52
+ if (input.shortName) return input.shortName;
53
+ const n = input.name || '';
54
+ return n.replace(/^gm:/, '').replace(/^software-development$/, 'software dev').replace(/-/g, ' ');
55
+ }
56
+ return SKILL_SLUG_LABELS[input] || input;
26
57
  }
27
58
 
28
- const RECENT_KEY = 'ds247420.recent.paths';
59
+ // Recent-cwd localStorage helpers. The two diverged copies used different
60
+ // keys (ds247420.recent.paths vs fd_recent_cwds) and different caps (10 vs
61
+ // 5) — keep the fd_recent_cwds key + cap 5 since that is the one every real
62
+ // call site (the OS kit's freddie chat page) actually reads/writes; a
63
+ // consumer of the ds247420.recent.paths key never existed (grep-confirmed
64
+ // zero other readers), so this is a safe consolidation, not a behavior cut.
65
+ const RECENT_KEY = 'fd_recent_cwds';
66
+ const RECENT_CAP = 5;
29
67
 
30
68
  export function getRecentPaths() {
31
69
  if (typeof localStorage === 'undefined') return [];
@@ -37,7 +75,7 @@ export function saveRecentPath(path) {
37
75
  if (typeof localStorage === 'undefined' || !path) return;
38
76
  const list = getRecentPaths().filter(p => p !== path);
39
77
  list.unshift(path);
40
- try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, 10))); } catch {}
78
+ try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_CAP))); } catch {}
41
79
  }
42
80
 
43
81
  // Helper used by consumers that want to render chat-message arrays with