anentrypoint-design 0.0.228 → 0.0.230

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.228",
3
+ "version": "0.0.230",
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…".
@@ -256,6 +256,12 @@ function ToolCallNode(p) {
256
256
  }
257
257
 
258
258
  function ThinkingNode(p) {
259
+ if (p.settled) {
260
+ return h('details', { class: 'chat-bubble chat-thinking-settled' },
261
+ h('summary', {}, 'View thinking'),
262
+ h('div', { class: 'chat-thinking-body' }, p.text)
263
+ );
264
+ }
259
265
  return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
260
266
  h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
261
267
  h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
@@ -271,8 +277,11 @@ const PART_RENDERERS = {
271
277
  // the tail-window path ('streaming · N KB so far').
272
278
  ? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
273
279
  ...[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 || '')),
280
+ h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
281
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
282
+ : h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
283
+ ...renderInline(p.text || ''),
284
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
276
285
  md: (p) => MdNode(p),
277
286
  code: (p) => CodeNode(p),
278
287
  tool: (p) => ToolCallNode(p),
@@ -354,7 +363,10 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
354
363
  // AND already shows content (so the inline typing dots have stopped), append
355
364
  // a thin caret so the live edge reads as "still writing", not "done". Drawn as
356
365
  // a CSS element, not a glyph character.
357
- if (streaming && !typing) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
366
+ // Only append the caret as a sibling if the last part did not already embed
367
+ // it inline (streamingCaret flag on the last text/md part in parts array).
368
+ const lastPartHasCaret = parts && parts.length && parts[parts.length - 1] && parts[parts.length - 1].streamingCaret;
369
+ if (streaming && !typing && !lastPartHasCaret) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
358
370
  // Out-of-band turn notices, plain copy in a NEUTRAL tone (not error red):
359
371
  // stopped — the turn was cancelled (locally or remotely); truncated
360
372
  // output must not read as a finished answer.
@@ -424,7 +436,7 @@ function flashComposerNote(composerEl, text) {
424
436
  note._dsNoteTimer = setTimeout(() => { note.remove(); }, 2600);
425
437
  }
426
438
 
427
- export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context, onPasteFiles, onDropFiles }) {
439
+ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, disabledReason, label, context, onPasteFiles, onDropFiles }) {
428
440
  // Keep a handle to the live textarea so send() reads the actual DOM value
429
441
  // (not the possibly-lagging `value` prop) and so we can sync the DOM value
430
442
  // only when it genuinely differs — re-applying `value` on every parent
@@ -495,7 +507,7 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
495
507
  }, b.text));
496
508
  else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
497
509
  });
498
- contextLine = h('div', { class: 'chat-composer-context' }, ...kids);
510
+ contextLine = h('div', { class: 'chat-composer-context', role: 'group', 'aria-label': 'active session: ' + ctxBits.map((b) => b.text).join(', ') }, ...kids);
499
511
  } else if (ctxBits.length) {
500
512
  const joined = ctxBits.map((b) => b.text).join(' · ');
501
513
  contextLine = h(context.onClick ? 'button' : 'div', {
@@ -506,7 +518,7 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
506
518
  }
507
519
  const hasDraft = !!(value && value.trim());
508
520
  return h('div', {
509
- class: 'chat-composer' + (hasDraft ? ' has-draft' : ''),
521
+ class: 'chat-composer' + (hasDraft ? ' has-draft' : '') + (disabled ? ' is-disabled' : ''),
510
522
  // A drop on the composer must NEVER navigate the browser away from the
511
523
  // live session: preventDefault on both dragover and drop, route files to
512
524
  // the optional onDropFiles handler, ring via .dragover.
@@ -523,23 +535,26 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
523
535
  },
524
536
  },
525
537
  contextLine,
526
- h('textarea', { ref: taRef, placeholder, rows: 1, 'aria-label': 'message input',
538
+ h('textarea', { ref: taRef, placeholder, rows: 1,
539
+ 'aria-label': label || (disabled && disabledReason ? 'message input — ' + disabledReason : 'message input'),
527
540
  disabled: !!disabled, 'aria-disabled': disabled ? 'true' : null,
528
541
  oninput: autoGrow,
529
542
  onpaste: (e) => {
530
543
  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')) {
544
+ // If the clipboard contains files, always route them — even when
545
+ // text is also present (some apps attach a filename as text).
546
+ if (cd && cd.files && cd.files.length) {
534
547
  e.preventDefault();
535
548
  if (onPasteFiles) onPasteFiles(cd.files);
536
549
  else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
537
550
  }
538
551
  },
539
552
  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; }
553
+ // Escape blurs the textarea when idle; stops generation when busy.
554
+ if (e.key === 'Escape') {
555
+ if (!busy) { e.currentTarget.blur(); return; }
556
+ if (onCancel) { e.preventDefault(); onCancel(e); return; }
557
+ }
543
558
  // IME guard: the Enter that commits a CJK composition must never
544
559
  // send (isComposing; keyCode 229 covers older engines).
545
560
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); send(); }
@@ -555,7 +570,9 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
555
570
  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
571
  busy && onCancel
557
572
  ? 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'))
573
+ : h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
574
+ 'aria-label': disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message',
575
+ title: disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message (Enter)' }, Icon('arrow-up'))
559
576
  )
560
577
  );
561
578
  }
@@ -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
  }),
@@ -391,10 +398,22 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
391
398
  }, ch))));
392
399
  };
393
400
 
401
+ const tabNavKey = (e) => {
402
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
403
+ const tabs = rootEl ? [...rootEl.querySelectorAll('.ov-emoji-tab')] : [];
404
+ if (!tabs.length) return;
405
+ const idx = tabs.indexOf(document.activeElement);
406
+ if (idx < 0) return;
407
+ e.preventDefault();
408
+ const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
409
+ tabs[next].focus();
410
+ tabs[next].click();
411
+ };
412
+
394
413
  return h('div', {
395
- class: 'ov-emoji-root', role: 'dialog', 'aria-label': 'Emoji picker',
414
+ class: 'ov-emoji-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Emoji picker',
396
415
  tabindex: '-1',
397
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) _trapTab(rootEl, e); },
416
+ onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } tabNavKey(e); if (rootEl) trapTab(rootEl, e); },
398
417
  ref: (el) => {
399
418
  if (!el) { if (rootEl && rootEl._ovEmojiCleanup) rootEl._ovEmojiCleanup(); return; }
400
419
  if (el._ovEmoji) return; el._ovEmoji = true; rootEl = el;
@@ -405,6 +424,7 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
405
424
  ...EMOJI_CATEGORIES.map((c) => h('button', {
406
425
  type: 'button', class: 'ov-emoji-tab', role: 'tab',
407
426
  'aria-selected': c.id === cat ? 'true' : 'false',
427
+ 'aria-controls': 'ov-emoji-panel',
408
428
  onclick: (e) => {
409
429
  cat = c.id;
410
430
  const tabs = rootEl.querySelectorAll('.ov-emoji-tab');
@@ -413,7 +433,9 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
413
433
  renderGrid();
414
434
  },
415
435
  }, c.label))),
416
- h('div', { class: 'ov-emoji-grid',
436
+ h('div', {
437
+ class: 'ov-emoji-grid', id: 'ov-emoji-panel', role: 'tabpanel',
438
+ 'aria-label': EMOJI_CATEGORIES.find(c => c.id === cat)?.label || EMOJI_CATEGORIES[0].label,
417
439
  ref: (el) => { if (!el) return; gridEl = el; queueMicrotask(renderGrid); } })
418
440
  );
419
441
  }
@@ -495,8 +517,8 @@ export function SettingsPopover({ title = 'Settings', open, anchorX = 0, anchorY
495
517
  };
496
518
 
497
519
  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); },
520
+ class: 'ov-set-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': String(title), tabindex: '-1',
521
+ onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) trapTab(rootEl, e); },
500
522
  ref: (el) => {
501
523
  if (!el) { if (rootEl && rootEl._ovSetCleanup) rootEl._ovSetCleanup(); return; }
502
524
  if (el._ovSet) return; el._ovSet = true; rootEl = el;
@@ -570,15 +592,31 @@ export function AuthModal({ mode = 'extension', error = '', busy = false, open =
570
592
  h('h2', { class: 'ov-auth-title' }, 'Sign in'),
571
593
  h('button', { type: 'button', class: 'ov-auth-x', 'aria-label': 'close', onclick: close }, Icon('x'))
572
594
  ),
573
- h('div', { class: 'ov-auth-tabs', role: 'tablist' },
595
+ h('div', { class: 'ov-auth-tabs', role: 'tablist',
596
+ onkeydown: (e) => {
597
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
598
+ const panel = e.currentTarget.closest('.ov-auth-panel');
599
+ const tabs = panel ? [...panel.querySelectorAll('.ov-auth-tab')] : [];
600
+ if (!tabs.length) return;
601
+ const idx = tabs.indexOf(document.activeElement);
602
+ if (idx < 0) return;
603
+ e.preventDefault();
604
+ const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
605
+ tabs[next].focus();
606
+ onModeChange && onModeChange(modes[next].id);
607
+ },
608
+ },
574
609
  ...modes.map(m => h('button', {
575
610
  type: 'button', role: 'tab', key: 'am-' + m.id,
611
+ id: 'ov-auth-tab-' + m.id,
576
612
  class: 'ov-auth-tab' + (m.id === mode ? ' is-active' : ''),
577
613
  'aria-selected': m.id === mode ? 'true' : 'false',
614
+ 'aria-controls': 'ov-auth-panel',
578
615
  onclick: () => onModeChange && onModeChange(m.id),
579
616
  }, m.label))
580
617
  ),
581
- h('div', { class: 'ov-auth-body' }, ...body()),
618
+ h('div', { class: 'ov-auth-body', id: 'ov-auth-panel', role: 'tabpanel',
619
+ 'aria-labelledby': 'ov-auth-tab-' + mode }, ...body()),
582
620
  error ? h('div', { class: 'ov-auth-error', role: 'alert' }, String(error)) : null
583
621
  )
584
622
  );
@@ -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' },