anentrypoint-design 0.0.229 → 0.0.231

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.229",
3
+ "version": "0.0.231",
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",
@@ -234,6 +234,7 @@ export function AgentChat(props = {}) {
234
234
  if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
235
235
  else parts.push({ kind: 'text', text: part.text, mdShell: true });
236
236
  }
237
+ else if (!isStreaming && part.kind === 'thinking') parts.push({ kind: 'thinking', settled: true, text: part.text });
237
238
  else parts.push(part);
238
239
  }
239
240
  }
@@ -245,6 +246,15 @@ export function AgentChat(props = {}) {
245
246
  // The streaming caret rides the live assistant turn once it has body (the
246
247
  // empty-shell turn already shows the inline typing dots).
247
248
  const streaming = isStreaming && msgHasBody(m);
249
+ // Place the caret inline inside the last text/md part rather than as a
250
+ // sibling span (which renders as a block below the last bubble). Tag the
251
+ // last text part so PART_RENDERERS.text can append it as an inline child.
252
+ if (streaming && parts.length) {
253
+ const lastPart = parts[parts.length - 1];
254
+ if (lastPart && (lastPart.kind === 'text' || lastPart.kind === 'md')) {
255
+ parts[parts.length - 1] = { ...lastPart, streamingCaret: true };
256
+ }
257
+ }
248
258
  // Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
249
259
  // build the action row only for SETTLED messages (no actions mid-stream).
250
260
  let actions;
@@ -252,6 +262,7 @@ export function AgentChat(props = {}) {
252
262
  const built = [];
253
263
  if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
254
264
  if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
265
+ if (!isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry', onClick: () => onRetryMessage(m) });
255
266
  // With confirmEdit the host arms its own confirm affordance (onArmEdit)
256
267
  // instead of resending immediately; the kit stays stateless either way.
257
268
  if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
@@ -317,7 +328,7 @@ export function AgentChat(props = {}) {
317
328
  // code / cowork surface these after a turn, not only on an empty thread). Shown
318
329
  // only when not busy and the last message is an assistant turn with body.
319
330
  const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
320
- ? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
331
+ ? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups', 'aria-hidden': 'true' },
321
332
  ...followups.map((s, i) => h('button', {
322
333
  key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
323
334
  onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
@@ -376,8 +387,8 @@ export function AgentChat(props = {}) {
376
387
  onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
377
388
  ...(banners || []).filter(Boolean),
378
389
  h('div', { class: 'agentchat-head' },
379
- h('h2', { class: 'agentchat-title' }, name + (selectedModel ? ' · ' + selectedModel : '')),
380
- h('span', { class: 'agentchat-sub', 'aria-live': 'polite' },
390
+ h('h1', { class: 'agentchat-title' }, name + (selectedModel ? ' · ' + selectedModel : '')),
391
+ h('span', { class: 'agentchat-sub', 'aria-hidden': busy ? 'true' : null },
381
392
  // Derive the busy label from the same status prop the controls use, so a
382
393
  // reconnecting-while-streaming state reads one word everywhere instead of
383
394
  // the head saying "streaming…" while the controls say "reconnecting…".
@@ -8,6 +8,12 @@ import { isDegraded as isMarkdownDegraded } from '../markdown.js';
8
8
  import { register } from '../debug.js';
9
9
  import { Icon } from './shell.js';
10
10
  import { fmtFileSize } from './files.js';
11
+ import { EmojiPicker } from './overlay-primitives.js';
12
+
13
+ // Matches a trailing `:keyword` at the end of the composer draft (optionally
14
+ // preceded by whitespace/start-of-string) so typing `:smile` opens an inline
15
+ // filtered EmojiPicker without requiring the toolbar button or Ctrl+;.
16
+ const EMOJI_TRIGGER_RE = /(?:^|\s)(:([a-zA-Z0-9_+-]{0,24}))$/;
11
17
 
12
18
  const h = webjsx.createElement;
13
19
  let _stats = { messages: 0, lastKindCounts: {} };
@@ -256,6 +262,12 @@ function ToolCallNode(p) {
256
262
  }
257
263
 
258
264
  function ThinkingNode(p) {
265
+ if (p.settled) {
266
+ return h('details', { class: 'chat-bubble chat-thinking-settled' },
267
+ h('summary', {}, 'View thinking'),
268
+ h('div', { class: 'chat-thinking-body' }, p.text)
269
+ );
270
+ }
259
271
  return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
260
272
  h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
261
273
  h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
@@ -271,8 +283,11 @@ const PART_RENDERERS = {
271
283
  // the tail-window path ('streaming · N KB so far').
272
284
  ? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
273
285
  ...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
274
- h('pre', { key: 'pre' }, h('code', {}, p.text || ''))].filter(Boolean))
275
- : h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') }, ...renderInline(p.text || '')),
286
+ h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
287
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
288
+ : h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
289
+ ...renderInline(p.text || ''),
290
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
276
291
  md: (p) => MdNode(p),
277
292
  code: (p) => CodeNode(p),
278
293
  tool: (p) => ToolCallNode(p),
@@ -354,7 +369,10 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
354
369
  // AND already shows content (so the inline typing dots have stopped), append
355
370
  // a thin caret so the live edge reads as "still writing", not "done". Drawn as
356
371
  // a CSS element, not a glyph character.
357
- if (streaming && !typing) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
372
+ // Only append the caret as a sibling if the last part did not already embed
373
+ // it inline (streamingCaret flag on the last text/md part in parts array).
374
+ const lastPartHasCaret = parts && parts.length && parts[parts.length - 1] && parts[parts.length - 1].streamingCaret;
375
+ if (streaming && !typing && !lastPartHasCaret) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
358
376
  // Out-of-band turn notices, plain copy in a NEUTRAL tone (not error red):
359
377
  // stopped — the turn was cancelled (locally or remotely); truncated
360
378
  // output must not read as a finished answer.
@@ -424,7 +442,7 @@ function flashComposerNote(composerEl, text) {
424
442
  note._dsNoteTimer = setTimeout(() => { note.remove(); }, 2600);
425
443
  }
426
444
 
427
- export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context, onPasteFiles, onDropFiles }) {
445
+ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, disabledReason, label, context, onPasteFiles, onDropFiles }) {
428
446
  // Keep a handle to the live textarea so send() reads the actual DOM value
429
447
  // (not the possibly-lagging `value` prop) and so we can sync the DOM value
430
448
  // only when it genuinely differs — re-applying `value` on every parent
@@ -435,6 +453,24 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
435
453
  if (!v || disabled) return;
436
454
  if (onSend) onSend(v);
437
455
  };
456
+ const triggerMatch = EMOJI_TRIGGER_RE.exec(value || '');
457
+ // taEl is only assigned by taRef during DOM diffing, which happens AFTER
458
+ // this render function returns — so on first paint of a trigger it is
459
+ // still null here. Fall back to the live DOM textarea from the previous
460
+ // paint (same composer, content patched in place) so the picker anchors
461
+ // near the input instead of the viewport origin.
462
+ const anchorEl = taEl || (typeof document !== 'undefined' ? document.querySelector('.chat-composer textarea') : null);
463
+ const insertEmoji = (ch) => {
464
+ const v = (taEl && taEl.value) || value || '';
465
+ const m = EMOJI_TRIGGER_RE.exec(v);
466
+ const next = m ? (v.slice(0, m.index) + (m[0].startsWith(':') ? '' : v[m.index]) + ch + ' ') : (v + ch);
467
+ if (onInput) onInput(next);
468
+ if (taEl) {
469
+ taEl.value = next;
470
+ taEl.focus();
471
+ taEl.selectionStart = taEl.selectionEnd = next.length;
472
+ }
473
+ };
438
474
  let autoGrowScheduled = false;
439
475
  const autoGrow = (e) => {
440
476
  const ta = e.target;
@@ -495,7 +531,7 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
495
531
  }, b.text));
496
532
  else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
497
533
  });
498
- contextLine = h('div', { class: 'chat-composer-context' }, ...kids);
534
+ contextLine = h('div', { class: 'chat-composer-context', role: 'group', 'aria-label': 'active session: ' + ctxBits.map((b) => b.text).join(', ') }, ...kids);
499
535
  } else if (ctxBits.length) {
500
536
  const joined = ctxBits.map((b) => b.text).join(' · ');
501
537
  contextLine = h(context.onClick ? 'button' : 'div', {
@@ -505,8 +541,16 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
505
541
  }, joined);
506
542
  }
507
543
  const hasDraft = !!(value && value.trim());
544
+ const triggerPicker = triggerMatch ? EmojiPicker({
545
+ open: true,
546
+ anchorX: (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect().left : 0,
547
+ anchorY: (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect().top : 0,
548
+ query: triggerMatch[2] || '',
549
+ onSelect: (ch) => insertEmoji(ch),
550
+ onClose: () => { if (taEl) { const v = taEl.value.replace(EMOJI_TRIGGER_RE, (full, tail) => full.slice(0, full.length - tail.length)); if (onInput) onInput(v); taEl.value = v; taEl.focus(); } },
551
+ }) : null;
508
552
  return h('div', {
509
- class: 'chat-composer' + (hasDraft ? ' has-draft' : ''),
553
+ class: 'chat-composer' + (hasDraft ? ' has-draft' : '') + (disabled ? ' is-disabled' : ''),
510
554
  // A drop on the composer must NEVER navigate the browser away from the
511
555
  // live session: preventDefault on both dragover and drop, route files to
512
556
  // the optional onDropFiles handler, ring via .dragover.
@@ -523,23 +567,27 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
523
567
  },
524
568
  },
525
569
  contextLine,
526
- h('textarea', { ref: taRef, placeholder, rows: 1, 'aria-label': 'message input',
570
+ triggerPicker,
571
+ h('textarea', { ref: taRef, placeholder, rows: 1,
572
+ 'aria-label': label || (disabled && disabledReason ? 'message input — ' + disabledReason : 'message input'),
527
573
  disabled: !!disabled, 'aria-disabled': disabled ? 'true' : null,
528
574
  oninput: autoGrow,
529
575
  onpaste: (e) => {
530
576
  const cd = e.clipboardData;
531
- // Image/file clipboard data with no accompanying text: never
532
- // silently dropped route to onPasteFiles or tell the user.
533
- if (cd && cd.files && cd.files.length && !cd.getData('text')) {
577
+ // If the clipboard contains files, always route them — even when
578
+ // text is also present (some apps attach a filename as text).
579
+ if (cd && cd.files && cd.files.length) {
534
580
  e.preventDefault();
535
581
  if (onPasteFiles) onPasteFiles(cd.files);
536
582
  else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
537
583
  }
538
584
  },
539
585
  onkeydown: (e) => {
540
- // Escape stops generation (the stop button's "(Esc)" title is
541
- // now truthful) before falling through to any host blur handling.
542
- if (e.key === 'Escape' && busy && onCancel) { e.preventDefault(); onCancel(e); return; }
586
+ // Escape blurs the textarea when idle; stops generation when busy.
587
+ if (e.key === 'Escape') {
588
+ if (!busy) { e.currentTarget.blur(); return; }
589
+ if (onCancel) { e.preventDefault(); onCancel(e); return; }
590
+ }
543
591
  // IME guard: the Enter that commits a CJK composition must never
544
592
  // send (isComposing; keyCode 229 covers older engines).
545
593
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); send(); }
@@ -555,7 +603,9 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
555
603
  onMenu ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onMenu(e); }, 'aria-label': 'composer menu', title: 'more options' }, Icon('more-horizontal')) : null,
556
604
  busy && onCancel
557
605
  ? h('button', { type: 'button', class: 'send cancel', onclick: (e) => { e.preventDefault(); onCancel(e); }, 'aria-label': 'stop generating', title: 'stop generating (Esc)' }, Icon('square'))
558
- : h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send, 'aria-label': 'send message', title: 'send message (Enter)' }, Icon('arrow-up'))
606
+ : h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
607
+ 'aria-label': disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message',
608
+ title: disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message (Enter)' }, Icon('arrow-up'))
559
609
  )
560
610
  );
561
611
  }
@@ -8,13 +8,17 @@
8
8
  // ContextPane({ agent, model, cwd, toolCount, onSetCwd })
9
9
  //
10
10
  // Props:
11
- // agent : display name of the active agent (string) or falsy for "none"
12
- // model : model id/name (string) or falsy
13
- // cwd : the chat working directory (string) or falsy for server default
14
- // toolCount : number of tool calls running in the current live turn (>=0)
15
- // usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
16
- // session : OPTIONAL whole-conversation totals { turns, cost } shown as a block
17
- // onSetCwd : optional callback for the "set working directory" affordance
11
+ // agent : display name of the active agent (string) or falsy for "none"
12
+ // model : model id/name (string) or falsy
13
+ // cwd : the chat working directory (string) or falsy for server default
14
+ // toolCount : number of tool calls running in the current live turn (>=0)
15
+ // usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
16
+ // session : OPTIONAL whole-conversation totals { turns, cost } shown as a block
17
+ // recentFiles: OPTIONAL [{ path, time }] files touched by tool calls this
18
+ // session (most-recent first), rendered as a compact panel -
19
+ // Claude Desktop's context surfaces recently-touched files.
20
+ // onSetCwd : optional callback for the "set working directory" affordance
21
+ // onOpenFile : optional callback(path) for clicking a recent-files row
18
22
  //
19
23
  // No decorative glyphs — words + the kit's Icon SVGs only.
20
24
 
@@ -32,7 +36,7 @@ function fmtTok(n) {
32
36
  return (n / 1000000).toFixed(1) + 'M';
33
37
  }
34
38
 
35
- export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
39
+ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, recentFiles, onSetCwd, onOpenFile } = {}) {
36
40
  const running = Number(toolCount) > 0;
37
41
  const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
38
42
  const hasSession = session && (session.turns != null || session.cost != null);
@@ -93,6 +97,17 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
93
97
  if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: fmtDuration(usage.durationMs) }));
94
98
  panels.push(Panel({ title: 'last turn', children: tokRows }));
95
99
  }
100
+ // Recent files: files touched by tool calls this session, most-recent
101
+ // first, capped to 5 rows so the panel stays a glance not a log.
102
+ if (Array.isArray(recentFiles) && recentFiles.length) {
103
+ const fileRows = recentFiles.slice(0, 5).map((f) => Row({
104
+ title: f.path.split(/[/\\]/).filter(Boolean).pop() || f.path,
105
+ sub: f.path,
106
+ meta: f.time || undefined,
107
+ onClick: onOpenFile ? () => onOpenFile(f.path) : undefined,
108
+ }));
109
+ panels.push(Panel({ title: 'recent files', children: fileRows }));
110
+ }
96
111
  // The cwd action lives on the working-dir row above; no floating footer button.
97
112
  return h('div', { class: 'ds-context' }, ...panels);
98
113
  }
@@ -6,7 +6,14 @@ import { fileGlyph, fmtFileSize } from './files.js';
6
6
  import { highlightAllUnder } from '../highlight.js';
7
7
  const h = webjsx.createElement;
8
8
 
9
- // Monotonic id source for aria-labelledby wiring between a modal and its head.
9
+ // Stable per-call-site id source for aria-labelledby: we want one fixed id per
10
+ // logical dialog instance (rename, confirm, prompt, preview), NOT a monotonic
11
+ // counter that advances on every render and leaves the old aria-labelledby
12
+ // reference dangling. A WeakMap keyed on the options object would not survive
13
+ // re-renders, so we use a short random suffix minted ONCE per Modal() call
14
+ // inside the function body — the closure keeps it stable for that render tree.
15
+ // _modalSeq is retained only for external callers that may import it; it is no
16
+ // longer used internally.
10
17
  let _modalSeq = 0;
11
18
 
12
19
  function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
@@ -19,13 +26,6 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
19
26
  const modal = el.querySelector('.ds-modal');
20
27
  if (!modal) return;
21
28
 
22
- // Focus trap: handle Tab key to cycle focus within modal
23
- const focusables = modal.querySelectorAll(
24
- 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
25
- );
26
- const firstFocusable = focusables[0];
27
- const lastFocusable = focusables[focusables.length - 1];
28
-
29
29
  const handleKeydown = (e) => {
30
30
  // Escape closes the modal — unless a mutation is in flight (the live
31
31
  // busy state is read off the data-busy attribute, which re-renders;
@@ -36,12 +36,19 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
36
36
  if (onClose) onClose();
37
37
  return;
38
38
  }
39
- // Tab trapping
39
+ // Focus trap: re-query focusables on each Tab press so that buttons
40
+ // disabled mid-flight (busy state) are excluded from the cycle and
41
+ // do not break tab navigation.
40
42
  if (e.key === 'Tab') {
43
+ const focusables = modal.querySelectorAll(
44
+ 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
45
+ );
41
46
  if (focusables.length === 0) {
42
47
  e.preventDefault();
43
48
  return;
44
49
  }
50
+ const firstFocusable = focusables[0];
51
+ const lastFocusable = focusables[focusables.length - 1];
45
52
  if (e.shiftKey) {
46
53
  if (document.activeElement === firstFocusable) {
47
54
  e.preventDefault();
@@ -127,7 +134,11 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
127
134
  function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
128
135
  // Give the head a stable id so the dialog can point aria-labelledby at it,
129
136
  // exposing the title as the dialog's accessible name to screen readers.
130
- const headId = head != null ? ('ds-modal-head-' + (++_modalSeq)) : null;
137
+ // The id is minted once per Modal() call with a short random suffix so it
138
+ // stays constant across re-renders of the same dialog instance — an
139
+ // incrementing counter advances on every render, leaving the previous
140
+ // aria-labelledby reference pointing at a now-absent element.
141
+ const headId = head != null ? ('ds-modal-head-' + Math.random().toString(36).slice(2, 8)) : null;
131
142
  return Backdrop({
132
143
  onClose,
133
144
  kind,
@@ -462,7 +462,7 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
462
462
  return h('div', {
463
463
  class: 'ds-dropzone' + (kids.length ? ' ds-dropzone--wrap' : '') + (dragover ? ' dragover' : ''),
464
464
  ondragover: (e) => { e.preventDefault(); onDragOver && onDragOver(e); },
465
- ondragleave: (e) => { onDragLeave && onDragLeave(e); },
465
+ ondragleave: (e) => { if (!e.currentTarget.contains(e.relatedTarget)) { onDragLeave && onDragLeave(e); } },
466
466
  ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
467
467
  },
468
468
  h('div', { class: 'ds-dropzone-inner' },
@@ -498,12 +498,9 @@ export function UploadProgress({ items = [], onDismiss } = {}) {
498
498
  return h('div', {
499
499
  key: it.name + i,
500
500
  class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
501
- role: 'progressbar',
502
- 'aria-valuenow': String(Math.max(0, Math.min(100, it.pct || 0))),
503
- 'aria-valuemin': '0',
504
- 'aria-valuemax': '100',
501
+ role: 'status',
505
502
  'aria-label': `${it.name}: ${status}`,
506
- 'aria-busy': it.done || it.error ? 'false' : 'true'
503
+ 'aria-live': 'polite'
507
504
  },
508
505
  h('span', { class: 'ds-upload-name' }, it.name),
509
506
  h('span', { class: 'ds-upload-bar' },
@@ -132,6 +132,7 @@ export function Popover({ open, anchorEl, onClose, placement = 'bottom-start', c
132
132
  const el = document.createElement('div');
133
133
  el.className = 'ds-popover';
134
134
  el.setAttribute('role', 'dialog');
135
+ el.setAttribute('aria-modal', 'true');
135
136
  if (ariaLabel) el.setAttribute('aria-label', ariaLabel);
136
137
  el.tabIndex = -1;
137
138
  document.body.appendChild(el);
@@ -246,7 +247,7 @@ function _clampToViewport(x, y, w, h, margin = CLAMP_MARGIN) {
246
247
 
247
248
  // Tab focus trap for a dialog root — keeps Tab/Shift+Tab cycling inside `el`.
248
249
  // Call from an onkeydown handler; returns true if it handled the event.
249
- function _trapTab(el, e) {
250
+ export function trapTab(el, e) {
250
251
  if (e.key !== 'Tab') return false;
251
252
  const nodes = el.querySelectorAll(FOCUSABLE_SEL);
252
253
  if (!nodes.length) { e.preventDefault(); return true; }
@@ -299,6 +300,7 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
299
300
  const hint = it.hint != null ? it.hint : (it.shortcut != null ? it.shortcut : null);
300
301
  out.push(h('button', {
301
302
  type: 'button', role: 'option',
303
+ id: 'ov-cmd-item-' + idx,
302
304
  'data-idx': String(idx),
303
305
  'aria-selected': idx === active ? 'true' : 'false',
304
306
  class: 'ov-cmd-item' + (idx === active ? ' is-active' : ''),
@@ -330,6 +332,7 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
330
332
  filtered.length ? rowsFor(filtered) : h('div', { class: 'ov-cmd-empty' }, 'No results')));
331
333
  const sel = listEl.querySelector('.ov-cmd-item.is-active');
332
334
  if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' });
335
+ if (inputEl) inputEl.setAttribute('aria-activedescendant', filtered.length ? 'ov-cmd-item-' + active : '');
333
336
  };
334
337
 
335
338
  const onKey = (e) => {
@@ -349,11 +352,15 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
349
352
  });
350
353
  },
351
354
  },
352
- h('div', { class: 'ov-cmd-panel', role: 'dialog', 'aria-label': 'Command palette', onkeydown: onKey },
355
+ h('div', { class: 'ov-cmd-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette', onkeydown: onKey },
353
356
  h('input', {
354
357
  type: 'text', class: 'ov-cmd-input', placeholder: 'Type a command…',
355
358
  'aria-label': 'command search',
359
+ role: 'combobox',
360
+ 'aria-autocomplete': 'list',
361
+ 'aria-expanded': 'true',
356
362
  'aria-controls': 'ov-cmd-list',
363
+ 'aria-activedescendant': '',
357
364
  oninput: (e) => { filterText = e.target.value; active = 0; renderInner(); },
358
365
  ref: (el) => { if (!el || el._ovCmdIn) return; el._ovCmdIn = true; inputEl = el; queueMicrotask(() => el.focus()); },
359
366
  }),
@@ -368,14 +375,38 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
368
375
  // per-emoji <button> labels below. This is intentional product content, not
369
376
  // decorative chrome.
370
377
  const EMOJI_CATEGORIES = [
371
- { id: 'smileys', label: '😀', emoji: ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😅','😉','🙂','😇','🥳','😴','🤩','😜','😢','😭','😡','😱','🥺','😤','😬'] },
372
- { id: 'gestures', label: '👍', emoji: ['👍','👎','👌','✌️','🤞','🙏','👏','🙌','💪','👀','🤝','✋','🤙','👋','🤟','☝️'] },
373
- { id: 'hearts', label: '❤️', emoji: ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','💔','💕','💖','💗'] },
374
- { id: 'symbols', label: '', emoji: ['🔥','💯','','','','🎉','🎊','✨','💡','⚡','💢','💀','🚀','🏆'] },
378
+ { id: 'smileys', label: '😀', emoji: [
379
+ ['😀', 'grinning smile'], ['😁', 'grinning smile happy'], ['😂', 'joy tears laugh'], ['🤣', 'rofl laugh'],
380
+ ['😊', 'smile blush happy'], ['😍', 'heart eyes love'], ['😘', 'kiss'], ['😎', 'cool sunglasses'],
381
+ ['🤔', 'thinking'], ['😅', 'sweat smile'], ['😉', 'wink'], ['🙂', 'smile slight'],
382
+ ['😇', 'angel innocent'], ['🥳', 'party'], ['😴', 'sleep'], ['🤩', 'starstruck'],
383
+ ['😜', 'wink tongue'], ['😢', 'cry sad'], ['😭', 'sob cry'], ['😡', 'angry mad'],
384
+ ['😱', 'scream shock'], ['🥺', 'pleading'], ['😤', 'huff'], ['😬', 'grimace'],
385
+ ] },
386
+ { id: 'gestures', label: '👍', emoji: [
387
+ ['👍', 'thumbsup yes good'], ['👎', 'thumbsdown no bad'], ['👌', 'ok'], ['✌️', 'peace'],
388
+ ['🤞', 'fingers crossed'], ['🙏', 'pray thanks'], ['👏', 'clap'], ['🙌', 'raised hands'],
389
+ ['💪', 'muscle strong'], ['👀', 'eyes look'], ['🤝', 'handshake'], ['✋', 'hand stop'],
390
+ ['🤙', 'call'], ['👋', 'wave hi bye'], ['🤟', 'love you'], ['☝️', 'point up'],
391
+ ] },
392
+ { id: 'hearts', label: '❤️', emoji: [
393
+ ['❤️', 'heart love red'], ['🧡', 'heart orange'], ['💛', 'heart yellow'], ['💚', 'heart green'],
394
+ ['💙', 'heart blue'], ['💜', 'heart purple'], ['🖤', 'heart black'], ['🤍', 'heart white'],
395
+ ['💔', 'broken heart'], ['💕', 'hearts'], ['💖', 'sparkling heart'], ['💗', 'growing heart'],
396
+ ] },
397
+ { id: 'symbols', label: '✅', emoji: [
398
+ ['🔥', 'fire lit'], ['💯', 'hundred'], ['✅', 'check yes done'], ['❌', 'cross no'],
399
+ ['⭐', 'star'], ['🎉', 'party tada'], ['🎊', 'confetti'], ['✨', 'sparkles'],
400
+ ['💡', 'idea lightbulb'], ['⚡', 'zap lightning'], ['💢', 'anger'], ['💀', 'skull dead'],
401
+ ['🚀', 'rocket launch'], ['🏆', 'trophy win'],
402
+ ] },
375
403
  ];
404
+ const ALL_EMOJI = EMOJI_CATEGORIES.flatMap((c) => c.emoji);
376
405
 
377
406
  // EmojiPicker — fixed popover near (anchorX, anchorY) with category tabs + grid.
378
- export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose } = {}) {
407
+ // `query`, when non-empty, filters across all categories by name/keyword
408
+ // substring match (case-insensitive) instead of showing the active tab.
409
+ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose, query = '' } = {}) {
379
410
  if (!open) return null;
380
411
  let cat = EMOJI_CATEGORIES[0].id;
381
412
  let rootEl = null, gridEl = null;
@@ -383,28 +414,44 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
383
414
 
384
415
  const renderGrid = () => {
385
416
  if (!gridEl) return;
386
- const c = EMOJI_CATEGORIES.find(x => x.id === cat) || EMOJI_CATEGORIES[0];
417
+ const q = (query || '').trim().toLowerCase();
418
+ const cells = q
419
+ ? ALL_EMOJI.filter(([, name]) => name.toLowerCase().includes(q))
420
+ : (EMOJI_CATEGORIES.find(x => x.id === cat) || EMOJI_CATEGORIES[0]).emoji;
387
421
  webjsx.applyDiff(gridEl, h('div', { class: 'ov-emoji-grid-inner' },
388
- ...c.emoji.map((ch) => h('button', {
389
- type: 'button', class: 'ov-emoji-cell', 'aria-label': ch,
422
+ cells.length ? cells.map(([ch, name]) => h('button', {
423
+ type: 'button', class: 'ov-emoji-cell', 'aria-label': name || ch, title: name || ch,
390
424
  onclick: () => { if (onSelect) onSelect(ch); },
391
- }, ch))));
425
+ }, ch)) : h('div', { class: 'ov-emoji-empty' }, 'no emoji found')));
426
+ };
427
+
428
+ const tabNavKey = (e) => {
429
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
430
+ const tabs = rootEl ? [...rootEl.querySelectorAll('.ov-emoji-tab')] : [];
431
+ if (!tabs.length) return;
432
+ const idx = tabs.indexOf(document.activeElement);
433
+ if (idx < 0) return;
434
+ e.preventDefault();
435
+ const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
436
+ tabs[next].focus();
437
+ tabs[next].click();
392
438
  };
393
439
 
394
440
  return h('div', {
395
- class: 'ov-emoji-root', role: 'dialog', 'aria-label': 'Emoji picker',
441
+ class: 'ov-emoji-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Emoji picker',
396
442
  tabindex: '-1',
397
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) _trapTab(rootEl, e); },
443
+ onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } tabNavKey(e); if (rootEl) trapTab(rootEl, e); },
398
444
  ref: (el) => {
399
445
  if (!el) { if (rootEl && rootEl._ovEmojiCleanup) rootEl._ovEmojiCleanup(); return; }
400
446
  if (el._ovEmoji) return; el._ovEmoji = true; rootEl = el;
401
447
  el._ovEmojiCleanup = _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW: 260, fallbackH: 240, close });
402
448
  },
403
449
  },
404
- h('div', { class: 'ov-emoji-tabs', role: 'tablist' },
450
+ (query || '').trim() ? null : h('div', { class: 'ov-emoji-tabs', role: 'tablist' },
405
451
  ...EMOJI_CATEGORIES.map((c) => h('button', {
406
452
  type: 'button', class: 'ov-emoji-tab', role: 'tab',
407
453
  'aria-selected': c.id === cat ? 'true' : 'false',
454
+ 'aria-controls': 'ov-emoji-panel',
408
455
  onclick: (e) => {
409
456
  cat = c.id;
410
457
  const tabs = rootEl.querySelectorAll('.ov-emoji-tab');
@@ -413,7 +460,9 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
413
460
  renderGrid();
414
461
  },
415
462
  }, c.label))),
416
- h('div', { class: 'ov-emoji-grid',
463
+ h('div', {
464
+ class: 'ov-emoji-grid', id: 'ov-emoji-panel', role: 'tabpanel',
465
+ 'aria-label': EMOJI_CATEGORIES.find(c => c.id === cat)?.label || EMOJI_CATEGORIES[0].label,
417
466
  ref: (el) => { if (!el) return; gridEl = el; queueMicrotask(renderGrid); } })
418
467
  );
419
468
  }
@@ -495,8 +544,8 @@ export function SettingsPopover({ title = 'Settings', open, anchorX = 0, anchorY
495
544
  };
496
545
 
497
546
  return h('div', {
498
- class: 'ov-set-root', role: 'dialog', 'aria-label': String(title), tabindex: '-1',
499
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) _trapTab(rootEl, e); },
547
+ class: 'ov-set-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': String(title), tabindex: '-1',
548
+ onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) trapTab(rootEl, e); },
500
549
  ref: (el) => {
501
550
  if (!el) { if (rootEl && rootEl._ovSetCleanup) rootEl._ovSetCleanup(); return; }
502
551
  if (el._ovSet) return; el._ovSet = true; rootEl = el;
@@ -570,15 +619,31 @@ export function AuthModal({ mode = 'extension', error = '', busy = false, open =
570
619
  h('h2', { class: 'ov-auth-title' }, 'Sign in'),
571
620
  h('button', { type: 'button', class: 'ov-auth-x', 'aria-label': 'close', onclick: close }, Icon('x'))
572
621
  ),
573
- h('div', { class: 'ov-auth-tabs', role: 'tablist' },
622
+ h('div', { class: 'ov-auth-tabs', role: 'tablist',
623
+ onkeydown: (e) => {
624
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
625
+ const panel = e.currentTarget.closest('.ov-auth-panel');
626
+ const tabs = panel ? [...panel.querySelectorAll('.ov-auth-tab')] : [];
627
+ if (!tabs.length) return;
628
+ const idx = tabs.indexOf(document.activeElement);
629
+ if (idx < 0) return;
630
+ e.preventDefault();
631
+ const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
632
+ tabs[next].focus();
633
+ onModeChange && onModeChange(modes[next].id);
634
+ },
635
+ },
574
636
  ...modes.map(m => h('button', {
575
637
  type: 'button', role: 'tab', key: 'am-' + m.id,
638
+ id: 'ov-auth-tab-' + m.id,
576
639
  class: 'ov-auth-tab' + (m.id === mode ? ' is-active' : ''),
577
640
  'aria-selected': m.id === mode ? 'true' : 'false',
641
+ 'aria-controls': 'ov-auth-panel',
578
642
  onclick: () => onModeChange && onModeChange(m.id),
579
643
  }, m.label))
580
644
  ),
581
- h('div', { class: 'ov-auth-body' }, ...body()),
645
+ h('div', { class: 'ov-auth-body', id: 'ov-auth-panel', role: 'tabpanel',
646
+ 'aria-labelledby': 'ov-auth-tab-' + mode }, ...body()),
582
647
  error ? h('div', { class: 'ov-auth-error', role: 'alert' }, String(error)) : null
583
648
  )
584
649
  );
@@ -36,14 +36,16 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
36
36
  onSelect, onNew, newLabel = 'New chat',
37
37
  emptyText = 'No conversations yet', loading = false, error = null,
38
38
  loadingText = 'Loading conversations…' } = {}) {
39
- const rowFor = (s, i) => h('button', {
39
+ const rowFor = (s, i) => h('div', {
40
40
  // Stable key: prefer sid, else position - a missing/duplicate sid would make
41
41
  // key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
42
42
  key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
43
- type: 'button',
43
+ role: 'option',
44
+ tabindex: s.sid === selected ? '0' : '-1',
44
45
  class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : ''),
45
- 'aria-current': s.sid === selected ? 'true' : null,
46
+ 'aria-selected': s.sid === selected ? 'true' : 'false',
46
47
  onclick: () => onSelect && onSelect(s),
48
+ onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect && onSelect(s); } },
47
49
  },
48
50
  // Positional children must NOT mix keyed VElements with null/strings (webjsx
49
51
  // applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
@@ -57,7 +59,14 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
57
59
  ].filter(Boolean)),
58
60
  h('span', { class: 'ds-session-meta' }, [
59
61
  s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
60
- s.running
62
+ // Optional richer status ('error'|'stale'|'running'|'stopping') mirrors the
63
+ // SessionCard STATUS_DISC mapping used on the Live dashboard, so a session
64
+ // pinned to a "Running" rail group reads the same stuck-vs-busy signal it
65
+ // does there rather than only a boolean live dot. Falls back to the plain
66
+ // running dot when no status is supplied (existing callers unaffected).
67
+ s.status
68
+ ? h('span', { class: 'status-dot-disc ' + (STATUS_DISC[s.status] || 'status-dot-live'), 'aria-label': STATUS_WORD[s.status] || s.status, role: 'img' })
69
+ : s.running
61
70
  ? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
62
71
  : (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
63
72
  ].filter(Boolean)));
@@ -84,11 +93,11 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
84
93
  const bySid = new Map(sessions.map((s) => [s.sid, s]));
85
94
  inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
86
95
  h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
87
- h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'list' }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).map(rowFor))));
96
+ h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'listbox', 'aria-label': g.label }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).map(rowFor))));
88
97
  } else {
89
98
  inner = sessions.map(rowFor);
90
99
  }
91
- const body = h('div', { key: 'body', class: 'ds-session-list', role: 'list' }, ...inner);
100
+ const body = h('div', { key: 'body', class: 'ds-session-list', role: 'listbox', 'aria-label': caption || 'Conversations' }, ...inner);
92
101
 
93
102
  return h('div', { class: 'ds-sessions' },
94
103
  h('div', { key: 'head', class: 'ds-session-head' },