anentrypoint-design 0.0.365 → 0.0.367

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.365",
3
+ "version": "0.0.367",
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",
@@ -10,6 +10,7 @@ import { fmtFileSize } from './files.js';
10
10
  import { EmojiPicker } from './overlay-primitives.js';
11
11
  import { t } from '../i18n.js';
12
12
  import { renderMessagePart as sharedRenderMessagePart, safeUrl as sharedSafeUrl, renderInline as sharedRenderInline, injectCodeCopy as sharedInjectCodeCopy } from './chat-message-parts.js';
13
+ import { avatarInitial } from './content.js';
13
14
 
14
15
  // Matches a trailing `:keyword` at the end of the composer draft (optionally
15
16
  // preceded by whitespace/start-of-string) so typing `:smile` opens an inline
@@ -119,7 +120,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
119
120
  const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '') + (isFlat ? ' chat-msg-flat' : '');
120
121
  const fallbackAvatar = avatar != null
121
122
  ? avatar
122
- : (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?'));
123
+ : (resolvedWho === 'you' ? 'u' : avatarInitial(name));
123
124
  const av = h('span', { class: 'chat-avatar' }, fallbackAvatar);
124
125
  let bodyNodes;
125
126
  if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as webjsx from '../../vendor/webjsx/index.js';
4
4
  import { Icon } from './shell.js';
5
+ import { Avatar, avatarInitial } from './content.js';
5
6
  import { sanitizeHtml } from '../markdown.js';
6
7
  const h = webjsx.createElement;
7
8
 
@@ -10,7 +11,6 @@ const h = webjsx.createElement;
10
11
  const clampCount = (n) => { const v = Number(n) || 0; return v > 99 ? '99+' : String(v); };
11
12
 
12
13
  export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
13
- const initials = (name || '?').slice(0, 2).toUpperCase();
14
14
  return h('div', {
15
15
  class: 'cm-server-icon' + (active ? ' active' : ''),
16
16
  onclick: onClick,
@@ -27,7 +27,7 @@ export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
27
27
  }
28
28
  },
29
29
  h('span', { class: 'cm-server-pill' }),
30
- icon ? h('img', { src: icon, alt: name }) : h('span', {}, initials),
30
+ icon ? h('img', { src: icon, alt: name }) : Avatar({ name, shape: 'square', initialsCount: 2 }),
31
31
  badge ? h('span', { class: 'cm-server-badge' }, badge > 99 ? '99+' : String(badge)) : null
32
32
  );
33
33
  }
@@ -88,7 +88,7 @@ export function ChannelItem({ id, name, type = 'text', active, voiceActive, voic
88
88
  ),
89
89
  voiceActive && participants.length ? h('div', { class: 'cm-ch-voice-users' },
90
90
  ...participants.map(p => h('div', { class: 'cm-ch-voice-user' + (p.speaking ? ' speaking' : '') },
91
- h('div', { class: 'cm-ch-voice-user-avatar', style: p.color ? `--avatar-bg:${p.color}` : null }, (p.identity || '?').slice(0, 1).toUpperCase()),
91
+ h('div', { class: 'cm-ch-voice-user-avatar', style: p.color ? `--avatar-bg:${p.color}` : null }, avatarInitial(p.identity)),
92
92
  h('span', { class: 'cm-ch-voice-user-name' }, p.identity)
93
93
  ))
94
94
  ) : null
@@ -120,7 +120,7 @@ export function ChannelCategory({ id, name, channels = [], collapsed, activeId,
120
120
  }
121
121
 
122
122
  export function VoiceUser({ identity, speaking, color } = {}) {
123
- const initial = (identity || '?').slice(0, 1).toUpperCase();
123
+ const initial = avatarInitial(identity);
124
124
  return h('div', { class: 'cm-voice-user' + (speaking ? ' speaking' : '') },
125
125
  h('div', { class: 'cm-voice-user-avatar', style: color ? `--avatar-bg:${color}` : null }, initial),
126
126
  h('span', { class: 'cm-voice-user-name' }, identity)
@@ -128,7 +128,7 @@ export function VoiceUser({ identity, speaking, color } = {}) {
128
128
  }
129
129
 
130
130
  export function UserPanel({ name, tag, color, muted, deafened, onMute, onDeafen, onSettings } = {}) {
131
- const initial = (name || '?').slice(0, 1).toUpperCase();
131
+ const initial = avatarInitial(name);
132
132
  const handleSettings = (e) => {
133
133
  e.preventDefault();
134
134
  if (onSettings) {
@@ -204,7 +204,7 @@ export function ChannelSidebar({ serverName, channels = [], categories = [], act
204
204
  }
205
205
 
206
206
  export function MemberItem({ identity, name, color, status = 'online' } = {}) {
207
- const initial = (name || identity || '?').slice(0, 1).toUpperCase();
207
+ const initial = avatarInitial(name || identity);
208
208
  return h('div', { class: 'cm-member-item' },
209
209
  h('div', { class: 'cm-member-avatar', style: color ? `--avatar-bg:${color}` : null },
210
210
  h('span', { class: 'cm-member-status' + (status === 'online' ? ' online' : '') }),
@@ -3,17 +3,33 @@
3
3
  // ProjectView, Form. Pure factories.
4
4
 
5
5
  import * as webjsx from '../../vendor/webjsx/index.js';
6
- import { Btn, Heading, Lede, Dot, Icon } from './shell.js';
6
+ import { Btn, Heading, Lede, Dot, Icon, Chip } from './shell.js';
7
7
  const h = webjsx.createElement;
8
8
 
9
9
  // Avatar — generic identity disc: an image when `src` resolves, else a
10
10
  // letter fallback derived from `name`/`fallback`. Kit previously only had
11
11
  // scoped one-offs (chat.js `.chat-avatar`, community.js `.cm-user-avatar`)
12
12
  // duplicating this same letter-fallback logic; this is the reusable version.
13
- export function Avatar({ name, src, fallback, size = 'md', key } = {}) {
14
- const letter = fallback != null ? fallback
15
- : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?');
16
- const cls = 'ds-avatar ds-avatar-' + size;
13
+ // avatarInitial the single shared letter-fallback computation behind
14
+ // Avatar and every custom-colored avatar wrapper (community.js's voice/user/
15
+ // member rows use their own `--avatar-bg` CSS-variable styling and can't
16
+ // drop in the Avatar element directly, but still want the SAME fallback
17
+ // text, not their own independently-drifting .slice(0,n).toUpperCase()).
18
+ // Empty-guards to '?' identically everywhere it's used.
19
+ export function avatarInitial(name, count = 1) {
20
+ return name ? String(name).trim().slice(0, count).toUpperCase() || '?' : '?';
21
+ }
22
+
23
+ // Avatar — the single letter-fallback/image avatar primitive. `initialsCount`
24
+ // (default 1) controls how many leading characters of `name` become the
25
+ // fallback letters when no `src`/`fallback` is given (community.js's
26
+ // pill-shaped ServerIcon wants 2); `shape` ('circle' default, or 'square')
27
+ // covers non-circular consumers without each hand-rolling its own
28
+ // .slice(0,n).toUpperCase() (previously duplicated across 5+ call sites in
29
+ // community.js and chat.js with drifting char-counts/empty-guards).
30
+ export function Avatar({ name, src, fallback, size = 'md', shape = 'circle', initialsCount = 1, key } = {}) {
31
+ const letter = fallback != null ? fallback : avatarInitial(name, initialsCount);
32
+ const cls = 'ds-avatar ds-avatar-' + size + (shape === 'square' ? ' ds-avatar-square' : '');
17
33
  if (src) return h('img', { key, class: cls, src, alt: name || '', loading: 'lazy' });
18
34
  return h('span', { key, class: cls, 'aria-hidden': !!name, role: name ? 'img' : undefined, 'aria-label': name || undefined }, letter);
19
35
  }
@@ -474,6 +490,52 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
474
490
  }, ...row.map((c, j) => h('td', { key: j }, c == null ? '' : (typeof c === 'object' ? c : String(c)))))))));
475
491
  }
476
492
 
493
+ // HealthTable — generic health-check table: given an arbitrary
494
+ // `{checkName: value}` object, infers a Chip tone per row from the value's
495
+ // own type (boolean true/false -> ok/miss Chip, object -> truncated JSON
496
+ // string, else the raw value stringified) so a new backend health check
497
+ // appears with zero per-check hardcoding at the call site. Ported from
498
+ // freddie's health page (src/components/freddie.js's `health` page), which
499
+ // hand-rolled this exact inference inline; promoted here as a reusable
500
+ // primitive for any dashboard/monitoring surface with a health-check map
501
+ // (agentgui's Live tab included). `okLabel`/`missLabel` let a caller
502
+ // localize the two Chip strings; `jsonTruncate` caps the JSON.stringify
503
+ // length for object-shaped values (matches freddie's own truncation width).
504
+ export function HealthTable({ checks = {}, emptyText = 'no health data', okLabel = 'ok', missLabel = 'no', jsonTruncate = 60 } = {}) {
505
+ const entries = Object.entries(checks);
506
+ if (!entries.length) return h('div', { class: 'empty' }, emptyText);
507
+ const rows = entries.map(([name, v]) => {
508
+ let cell;
509
+ if (typeof v === 'object' && v !== null) {
510
+ const s = JSON.stringify(v);
511
+ cell = h('span', { title: s.length > jsonTruncate ? s : null }, s.length > jsonTruncate ? s.slice(0, jsonTruncate) + '…' : s);
512
+ } else if (v === true) cell = Chip({ tone: 'ok', children: okLabel });
513
+ else if (v === false) cell = Chip({ tone: 'miss', children: missLabel });
514
+ else cell = String(v);
515
+ return [name, cell];
516
+ });
517
+ return Table({ headers: ['check', 'status'], rows });
518
+ }
519
+
520
+ // ProcessRegistryTable — generic long-lived-process registry table: given a
521
+ // list of `{kind, key, state}`-shaped rows (any in-flight process the host
522
+ // tracks — an xstate machine, an ACP/direct-runner session, a background
523
+ // job), renders a uniform table. Ported from freddie's `machines` page
524
+ // (src/components/freddie.js), which used this exact shape for its xstate
525
+ // machine census; generalized here since the shape (kind/key/state) applies
526
+ // equally to agentgui's Live tab listing in-flight agent runner processes.
527
+ // `extraColumns` lets a caller append columns beyond the base three (e.g. a
528
+ // stop-action button) without forking the whole table.
529
+ export function ProcessRegistryTable({ processes = [], emptyText = 'no live processes', extraColumns = [] } = {}) {
530
+ if (!processes.length) return h('div', { class: 'empty' }, emptyText);
531
+ const headers = ['kind', 'key', 'state', ...extraColumns.map(c => c.header)];
532
+ const rows = processes.map(p => [
533
+ p.kind || '—', p.key || '—', p.state || '—',
534
+ ...extraColumns.map(c => c.render(p))
535
+ ]);
536
+ return Table({ headers, rows });
537
+ }
538
+
477
539
  export function HomeView({ state = {}, onNav, onToggleWork, works = [], posts = [], manifesto = [], currentlyShipping } = {}) {
478
540
  return [
479
541
  Hero({
@@ -117,3 +117,20 @@ export function emptyState(text = 'nothing here yet', glyph = Icon('circle')) {
117
117
  h('div', { class: 'fd-empty-glyph', 'aria-hidden': 'true' }, glyph),
118
118
  h('div', { class: 'dim' }, text));
119
119
  }
120
+
121
+ // refreshError — the never-blank-on-refresh-error convention: a monitoring
122
+ // page whose poll fails AFTER already having last-good data keeps that data
123
+ // visible and shows this non-blocking banner instead of falling back to
124
+ // errorState's full-page replacement. errorState (above) is still correct
125
+ // for the FIRST failed poll (no last-good data exists yet to keep showing) -
126
+ // callers gate on `err && !data` -> errorState, `err && data` -> refreshError
127
+ // alongside the still-rendered data, exactly as every freddie.js page does.
128
+ // Previously a private per-file const; promoted here so any monitoring
129
+ // surface (agentgui's Live tab included) can reuse the same convention
130
+ // instead of re-deriving its own banner markup.
131
+ export function refreshError(err) {
132
+ if (!err) return null;
133
+ return h('div', { class: 'ds-alert ds-alert-warn', role: 'status', 'aria-live': 'polite' },
134
+ h('span', { class: 'ds-alert-icon' }, '!'),
135
+ h('div', { class: 'ds-alert-content' }, 'refresh failed: ' + String(err.message || err)));
136
+ }
@@ -5,7 +5,7 @@
5
5
  // the single maintenance point for freddie GUI per the dynamic-stack contract.
6
6
 
7
7
  import * as webjsx from '../../vendor/webjsx/index.js';
8
- import { makePage, api, loadingState, errorState, emptyState } from './freddie/runtime.js';
8
+ import { makePage, api, loadingState, errorState, emptyState, refreshError } from './freddie/runtime.js';
9
9
  import { getRecentPaths, saveRecentPath, skillLabel, renderChatMessages } from './freddie/helpers.js';
10
10
  import { Panel, Row, Table, Kpi, PageHeader, SearchInput, TextField, Select } from './content.js';
11
11
  import { Chip, Btn, Icon } from './shell.js';
@@ -28,10 +28,6 @@ const noteAlert = (note) => note ? h('div', { class: 'ds-alert ds-alert-' + note
28
28
  h('div', { class: 'ds-alert-content' }, note.msg)) : null;
29
29
  // Manual refresh button for non-polling pages — parity with auto-refreshing ones.
30
30
  const refreshBtn = (onClick, busy) => Btn({ children: busy ? 'refreshing…' : [Icon('refresh'), ' refresh'], disabled: !!busy, onClick, 'aria-label': 'refresh' });
31
- // Non-blocking refresh-error banner: keep last-good content, surface the failure.
32
- const refreshError = (err) => err ? h('div', { class: 'ds-alert ds-alert-warn', role: 'status', 'aria-live': 'polite' },
33
- h('span', { class: 'ds-alert-icon' }, '!'),
34
- h('div', { class: 'ds-alert-content' }, 'refresh failed: ' + String(err.message || err))) : null;
35
31
  // Polite live region announcing async busy/done state to screen readers.
36
32
  const liveRegion = (msg) => h('div', { class: 'fd-sr-live', role: 'status', 'aria-live': 'polite' }, msg || '');
37
33
  // Truncate with a title tooltip carrying the full text.
@@ -189,11 +189,25 @@ export function Popover({ open, anchorEl, onClose, placement = 'bottom-start', c
189
189
  return null;
190
190
  }
191
191
 
192
- // Dropdownbutton trigger + portaled menu.
193
- export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-start', ariaLabel } = {}) {
192
+ // useRovingMenuthe shared open/close/outside-click/roving-nav/typeahead
193
+ // state machine behind Dropdown, PermissionMenu, and MenuButton. All three
194
+ // previously reimplemented an identical ~70-line skeleton (byte-identical
195
+ // close() teardown, near-identical onMenuKey); this factors it into one
196
+ // place so a fix/feature (e.g. typeahead) lands for every consumer instead
197
+ // of drifting per-copy. `itemSelector` picks the live focusable items inside
198
+ // the rendered menu (each consumer uses a different role: menuitem /
199
+ // menuitemcheckbox / menuitemradio); `getLabel(item)` + `items` enable
200
+ // typeahead when `typeahead` is true (Dropdown/MenuButton have it,
201
+ // PermissionMenu's categories aren't typically typeahead-searched so it
202
+ // defaults off but can opt in). Returns { refFn, onTrigClick, onTrigKey,
203
+ // openMenu, close, focusItem, isOpen } — the caller still owns rendering the
204
+ // menu's DOM/CSS (role/class per consumer stays distinct) and wires
205
+ // `menuEl.addEventListener('keydown', onMenuKey)` itself via the returned
206
+ // `onMenuKey`, since only the caller knows when its menuEl exists.
207
+ export function useRovingMenu({ itemSelector, items = [], getLabel = (it) => it.label, typeahead = false, placement = 'bottom-start', onOpenChange } = {}) {
194
208
  let triggerEl = null, open = false, menuEl = null, floating = null, typeBuf = '', typeTimer = null;
195
- const liveBtns = () => menuEl ? [...menuEl.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')] : [];
196
- const focusItem = (idx) => { const b = liveBtns(); if (!b.length) return; b[((idx % b.length) + b.length) % b.length].focus(); };
209
+ const liveItems = () => menuEl ? [...menuEl.querySelectorAll(itemSelector)] : [];
210
+ const focusItem = (idx) => { const b = liveItems(); if (!b.length) return; b[((idx % b.length) + b.length) % b.length].focus(); };
197
211
  const onDown = (e) => { if (menuEl && menuEl.contains(e.target)) return; if (triggerEl && triggerEl.contains(e.target)) return; close(false); };
198
212
  const close = (restore = true) => {
199
213
  if (!open) return; open = false;
@@ -203,32 +217,58 @@ export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-st
203
217
  document.removeEventListener('mousedown', onDown, true);
204
218
  if (triggerEl) triggerEl.setAttribute('aria-expanded', 'false');
205
219
  if (restore && triggerEl) triggerEl.focus();
220
+ if (onOpenChange) onOpenChange(false);
206
221
  };
207
- const select = (it) => { if (it.disabled || it.separator) return; if (onSelect) onSelect(it.id, it); close(); };
208
222
  const onMenuKey = (e) => {
209
- const b = liveBtns(), idx = b.indexOf(document.activeElement);
223
+ const b = liveItems(), idx = b.indexOf(document.activeElement);
210
224
  if (e.key === 'Escape') { e.preventDefault(); close(); }
211
225
  else if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(idx + 1); }
212
226
  else if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(idx - 1); }
213
227
  else if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
214
228
  else if (e.key === 'End') { e.preventDefault(); focusItem(b.length - 1); }
215
229
  else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (idx >= 0) b[idx].click(); }
216
- else if (e.key.length === 1 && /\S/.test(e.key)) {
230
+ else if (typeahead && e.key.length === 1 && /\S/.test(e.key)) {
217
231
  typeBuf += e.key.toLowerCase();
218
232
  if (typeTimer) clearTimeout(typeTimer);
219
233
  typeTimer = setTimeout(() => { typeBuf = ''; }, 600);
220
- const m = items.findIndex(it => !it.separator && !it.disabled && (it.label || '').toLowerCase().startsWith(typeBuf));
221
- if (m >= 0) focusItem(items.slice(0, m).filter(it => !it.separator && !it.disabled).length);
234
+ const selectable = items.filter(it => !it.separator && !it.disabled && !it.unavailable);
235
+ const m = selectable.findIndex(it => (getLabel(it) || '').toLowerCase().startsWith(typeBuf));
236
+ if (m >= 0) focusItem(m);
222
237
  }
223
238
  };
224
- const openMenu = (focusFirst = true) => {
239
+ const openMenu = (buildMenuEl, focusFirst = true) => {
225
240
  if (open || !triggerEl) return;
226
241
  open = true;
227
- menuEl = document.createElement('div');
228
- menuEl.className = 'ds-popover ds-dropdown-menu';
229
- menuEl.setAttribute('role', 'menu');
230
- if (ariaLabel) menuEl.setAttribute('aria-label', ariaLabel);
242
+ menuEl = buildMenuEl();
231
243
  menuEl.tabIndex = -1;
244
+ document.body.appendChild(menuEl);
245
+ menuEl.addEventListener('keydown', onMenuKey);
246
+ floating = useFloating(triggerEl, menuEl, { placement, offset: FLOAT_OFFSET_DROPDOWN });
247
+ document.addEventListener('mousedown', onDown, true);
248
+ triggerEl.setAttribute('aria-expanded', 'true');
249
+ if (focusFirst && liveItems().length) queueMicrotask(() => focusItem(0));
250
+ if (onOpenChange) onOpenChange(true);
251
+ };
252
+ const onTrigClick = (buildMenuEl) => { if (open) close(false); else openMenu(buildMenuEl, true); };
253
+ const onTrigKey = (e, buildMenuEl) => { if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!open) openMenu(buildMenuEl, true); else focusItem(0); } };
254
+ const refFn = (dsFlag) => (el) => {
255
+ if (!el || el[dsFlag]) return;
256
+ el[dsFlag] = true; triggerEl = el;
257
+ el.setAttribute('aria-haspopup', 'menu');
258
+ el.setAttribute('aria-expanded', 'false');
259
+ };
260
+ return { refFn, onTrigClick, onTrigKey, openMenu, close, focusItem, isOpen: () => open };
261
+ }
262
+
263
+ // Dropdown — button trigger + portaled menu.
264
+ export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-start', ariaLabel } = {}) {
265
+ const menu = useRovingMenu({ itemSelector: '[role="menuitem"]:not([aria-disabled="true"])', items, typeahead: true, placement });
266
+ const select = (it) => { if (it.disabled || it.separator) return; if (onSelect) onSelect(it.id, it); menu.close(); };
267
+ const buildMenuEl = () => {
268
+ const el = document.createElement('div');
269
+ el.className = 'ds-popover ds-dropdown-menu';
270
+ el.setAttribute('role', 'menu');
271
+ if (ariaLabel) el.setAttribute('aria-label', ariaLabel);
232
272
  const tree = h('div', { class: 'ds-dropdown-list' },
233
273
  ...items.map((it, i) => it.separator
234
274
  ? h('div', { key: 'sep' + i, class: 'ds-dropdown-separator', role: 'separator' })
@@ -241,28 +281,17 @@ export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-st
241
281
  it.glyph != null ? h('span', { class: 'ds-dropdown-glyph', 'aria-hidden': 'true' }, it.glyph) : null,
242
282
  h('span', { class: 'ds-dropdown-label' }, it.label)
243
283
  )));
244
- webjsx.applyDiff(menuEl, tree);
245
- document.body.appendChild(menuEl);
246
- menuEl.addEventListener('keydown', onMenuKey);
247
- floating = useFloating(triggerEl, menuEl, { placement, offset: FLOAT_OFFSET_DROPDOWN });
248
- document.addEventListener('mousedown', onDown, true);
249
- triggerEl.setAttribute('aria-expanded', 'true');
250
- if (focusFirst) queueMicrotask(() => focusItem(0));
251
- };
252
- const onTrigClick = () => { if (open) close(false); else openMenu(true); };
253
- const onTrigKey = (e) => { if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!open) openMenu(true); else focusItem(0); } };
254
- const refFn = (el) => {
255
- if (!el || el._dsDropdown) return;
256
- el._dsDropdown = true; triggerEl = el;
257
- el.addEventListener('click', onTrigClick);
258
- el.addEventListener('keydown', onTrigKey);
259
- el.setAttribute('aria-haspopup', 'menu');
260
- el.setAttribute('aria-expanded', 'false');
284
+ webjsx.applyDiff(el, tree);
285
+ return el;
261
286
  };
287
+ const onTrigClick = () => menu.onTrigClick(buildMenuEl);
288
+ const onTrigKey = (e) => menu.onTrigKey(e, buildMenuEl);
289
+ const refFn = menu.refFn('_dsDropdown');
262
290
  const child = (typeof trigger === 'function') ? trigger() : trigger;
291
+ const wireRef = (el) => { refFn(el); if (el) { el.addEventListener('click', onTrigClick); el.addEventListener('keydown', onTrigKey); } };
263
292
  return (child && child.type)
264
- ? webjsx.createElement(child.type, { ...(child.props || {}), ref: refFn }, ...(child.children || []))
265
- : h('button', { type: 'button', class: 'ds-dropdown-trigger', ref: refFn }, child || 'Menu');
293
+ ? webjsx.createElement(child.type, { ...(child.props || {}), ref: wireRef }, ...(child.children || []))
294
+ : h('button', { type: 'button', class: 'ds-dropdown-trigger', ref: wireRef }, child || 'Menu');
266
295
  }
267
296
 
268
297
  // PermissionMenu — a role=menu of role=menuitemcheckbox rows, one per
@@ -272,31 +301,14 @@ export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-st
272
301
  // element, a document-level mousedown listener, focus restored to the
273
302
  // trigger on close) rather than reimplementing that plumbing.
274
303
  export function PermissionMenu({ trigger, categories = [], approved = [], onToggle, onToggleAll, placement = 'bottom-start', ariaLabel = 'Permissions' } = {}) {
275
- let triggerEl = null, open = false, menuEl = null, floating = null;
276
304
  const isApproved = (id) => approved.indexOf(id) !== -1;
277
- const liveItems = () => menuEl ? [...menuEl.querySelectorAll('[role="menuitemcheckbox"]')] : [];
278
- const focusItem = (idx) => { const items = liveItems(); if (!items.length) return; items[((idx % items.length) + items.length) % items.length].focus(); };
279
- const onDown = (e) => { if (menuEl && menuEl.contains(e.target)) return; if (triggerEl && triggerEl.contains(e.target)) return; close(false); };
280
- const close = (restore = true) => {
281
- if (!open) return; open = false;
282
- if (floating) { floating.dispose(); floating = null; }
283
- if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl);
284
- menuEl = null;
285
- document.removeEventListener('mousedown', onDown, true);
286
- if (triggerEl) triggerEl.setAttribute('aria-expanded', 'false');
287
- if (restore && triggerEl) triggerEl.focus();
288
- };
305
+ const menu = useRovingMenu({ itemSelector: '[role="menuitemcheckbox"]', items: categories, getLabel: (cat) => cat.label || cat.id, typeahead: true, placement });
289
306
  const toggle = (cat) => { if (onToggle) onToggle(cat.id, !isApproved(cat.id)); };
290
- const onMenuKey = (e) => {
291
- const items = liveItems(), idx = items.indexOf(document.activeElement);
292
- if (e.key === 'Escape') { e.preventDefault(); close(); }
293
- else if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(idx + 1); }
294
- else if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(idx - 1); }
295
- else if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
296
- else if (e.key === 'End') { e.preventDefault(); focusItem(items.length - 1); }
297
- else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (idx >= 0) items[idx].click(); }
298
- };
299
- const renderMenu = () => {
307
+ const buildMenuEl = () => {
308
+ const el = document.createElement('div');
309
+ el.className = 'ds-popover ov-perm-menu';
310
+ el.setAttribute('role', 'menu');
311
+ el.setAttribute('aria-label', ariaLabel);
300
312
  const rows = categories.map((cat, i) => h('button', {
301
313
  key: cat.id || i, type: 'button', role: 'menuitemcheckbox',
302
314
  'aria-checked': isApproved(cat.id) ? 'true' : 'false',
@@ -307,38 +319,17 @@ export function PermissionMenu({ trigger, categories = [], approved = [], onTogg
307
319
  const actionsRow = h('div', { class: 'ov-perm-actions' },
308
320
  h('button', { type: 'button', class: 'ov-perm-action', onclick: () => onToggleAll && onToggleAll(true) }, 'Approve all'),
309
321
  h('button', { type: 'button', class: 'ov-perm-action', onclick: () => onToggleAll && onToggleAll(false) }, 'Revoke all'));
310
- return h('div', { class: 'ov-perm-list' }, ...rows, actionsRow);
311
- };
312
- const openMenu = (focusFirst = true) => {
313
- if (open || !triggerEl) return;
314
- open = true;
315
- menuEl = document.createElement('div');
316
- menuEl.className = 'ds-popover ov-perm-menu';
317
- menuEl.setAttribute('role', 'menu');
318
- menuEl.setAttribute('aria-label', ariaLabel);
319
- menuEl.tabIndex = -1;
320
- webjsx.applyDiff(menuEl, renderMenu());
321
- document.body.appendChild(menuEl);
322
- menuEl.addEventListener('keydown', onMenuKey);
323
- floating = useFloating(triggerEl, menuEl, { placement, offset: FLOAT_OFFSET_DROPDOWN });
324
- document.addEventListener('mousedown', onDown, true);
325
- triggerEl.setAttribute('aria-expanded', 'true');
326
- if (focusFirst) queueMicrotask(() => focusItem(0));
327
- };
328
- const onTrigClick = () => { if (open) close(false); else openMenu(true); };
329
- const onTrigKey = (e) => { if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!open) openMenu(true); else focusItem(0); } };
330
- const refFn = (el) => {
331
- if (!el || el._dsPermMenu) return;
332
- el._dsPermMenu = true; triggerEl = el;
333
- el.addEventListener('click', onTrigClick);
334
- el.addEventListener('keydown', onTrigKey);
335
- el.setAttribute('aria-haspopup', 'menu');
336
- el.setAttribute('aria-expanded', 'false');
322
+ webjsx.applyDiff(el, h('div', { class: 'ov-perm-list' }, ...rows, actionsRow));
323
+ return el;
337
324
  };
325
+ const onTrigClick = () => menu.onTrigClick(buildMenuEl);
326
+ const onTrigKey = (e) => menu.onTrigKey(e, buildMenuEl);
327
+ const refFn = menu.refFn('_dsPermMenu');
338
328
  const child = (typeof trigger === 'function') ? trigger() : trigger;
329
+ const wireRef = (el) => { refFn(el); if (el) { el.addEventListener('click', onTrigClick); el.addEventListener('keydown', onTrigKey); } };
339
330
  return (child && child.type)
340
- ? webjsx.createElement(child.type, { ...(child.props || {}), ref: refFn }, ...(child.children || []))
341
- : h('button', { type: 'button', class: 'ov-perm-trigger', ref: refFn }, child || 'Permissions');
331
+ ? webjsx.createElement(child.type, { ...(child.props || {}), ref: wireRef }, ...(child.children || []))
332
+ : h('button', { type: 'button', class: 'ov-perm-trigger', ref: wireRef }, child || 'Permissions');
342
333
  }
343
334
 
344
335
  // ApprovalPrompt — an inline, in-thread tool-permission card (as opposed to
@@ -800,44 +791,13 @@ export function AuthModal({ mode = 'extension', error = '', busy = false, open =
800
791
  // nothing focusable); roving nav on a single-option list simply refocuses
801
792
  // the same item on every Arrow press (wrap-to-self), never throws.
802
793
  export function MenuButton({ trigger, items = [], selected, onSelect, onRetry, placement = 'bottom-start', ariaLabel = 'Menu', emptyText = 'No options available' } = {}) {
803
- let triggerEl = null, open = false, menuEl = null, floating = null, typeBuf = '', typeTimer = null;
804
- const liveBtns = () => menuEl ? [...menuEl.querySelectorAll('[role="menuitemradio"]:not([aria-disabled="true"])')] : [];
805
- const focusItem = (idx) => { const b = liveBtns(); if (!b.length) return; b[((idx % b.length) + b.length) % b.length].focus(); };
806
- const onDown = (e) => { if (menuEl && menuEl.contains(e.target)) return; if (triggerEl && triggerEl.contains(e.target)) return; close(false); };
807
- const close = (restore = true) => {
808
- if (!open) return; open = false;
809
- if (floating) { floating.dispose(); floating = null; }
810
- if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl);
811
- menuEl = null;
812
- document.removeEventListener('mousedown', onDown, true);
813
- if (triggerEl) triggerEl.setAttribute('aria-expanded', 'false');
814
- if (restore && triggerEl) triggerEl.focus();
815
- };
816
- const select = (it) => { if (it.disabled || it.unavailable) return; if (onSelect) onSelect(it.id, it); close(); };
817
- const onMenuKey = (e) => {
818
- const b = liveBtns(), idx = b.indexOf(document.activeElement);
819
- if (e.key === 'Escape') { e.preventDefault(); close(); }
820
- else if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(idx + 1); }
821
- else if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(idx - 1); }
822
- else if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
823
- else if (e.key === 'End') { e.preventDefault(); focusItem(b.length - 1); }
824
- else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (idx >= 0) b[idx].click(); }
825
- else if (e.key.length === 1 && /\S/.test(e.key)) {
826
- typeBuf += e.key.toLowerCase();
827
- if (typeTimer) clearTimeout(typeTimer);
828
- typeTimer = setTimeout(() => { typeBuf = ''; }, 600);
829
- const m = items.findIndex(it => !it.disabled && !it.unavailable && (it.label || '').toLowerCase().startsWith(typeBuf));
830
- if (m >= 0) focusItem(items.slice(0, m).filter(it => !it.disabled && !it.unavailable).length);
831
- }
832
- };
833
- const openMenu = (focusFirst = true) => {
834
- if (open || !triggerEl) return;
835
- open = true;
836
- menuEl = document.createElement('div');
837
- menuEl.className = 'ds-popover ov-menubutton-menu';
838
- menuEl.setAttribute('role', 'menu');
839
- menuEl.setAttribute('aria-label', ariaLabel);
840
- menuEl.tabIndex = -1;
794
+ const menu = useRovingMenu({ itemSelector: '[role="menuitemradio"]:not([aria-disabled="true"])', items, typeahead: true, placement });
795
+ const select = (it) => { if (it.disabled || it.unavailable) return; if (onSelect) onSelect(it.id, it); menu.close(); };
796
+ const buildMenuEl = () => {
797
+ const el = document.createElement('div');
798
+ el.className = 'ds-popover ov-menubutton-menu';
799
+ el.setAttribute('role', 'menu');
800
+ el.setAttribute('aria-label', ariaLabel);
841
801
  const tree = items.length
842
802
  ? h('div', { class: 'ov-menubutton-list' },
843
803
  ...items.map((it, i) => it.unavailable
@@ -856,28 +816,17 @@ export function MenuButton({ trigger, items = [], selected, onSelect, onRetry, p
856
816
  h('span', { class: 'ov-menubutton-label' }, it.label)
857
817
  )))
858
818
  : h('div', { class: 'ov-menubutton-empty' }, emptyText);
859
- webjsx.applyDiff(menuEl, tree);
860
- document.body.appendChild(menuEl);
861
- menuEl.addEventListener('keydown', onMenuKey);
862
- floating = useFloating(triggerEl, menuEl, { placement, offset: FLOAT_OFFSET_DROPDOWN });
863
- document.addEventListener('mousedown', onDown, true);
864
- triggerEl.setAttribute('aria-expanded', 'true');
865
- if (focusFirst && items.length) queueMicrotask(() => focusItem(0));
866
- };
867
- const onTrigClick = () => { if (open) close(false); else openMenu(true); };
868
- const onTrigKey = (e) => { if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!open) openMenu(true); else focusItem(0); } };
869
- const refFn = (el) => {
870
- if (!el || el._ovMenuButton) return;
871
- el._ovMenuButton = true; triggerEl = el;
872
- el.addEventListener('click', onTrigClick);
873
- el.addEventListener('keydown', onTrigKey);
874
- el.setAttribute('aria-haspopup', 'menu');
875
- el.setAttribute('aria-expanded', 'false');
819
+ webjsx.applyDiff(el, tree);
820
+ return el;
876
821
  };
822
+ const onTrigClick = () => menu.onTrigClick(buildMenuEl);
823
+ const onTrigKey = (e) => menu.onTrigKey(e, buildMenuEl);
824
+ const refFn = menu.refFn('_ovMenuButton');
877
825
  const child = (typeof trigger === 'function') ? trigger() : trigger;
826
+ const wireRef = (el) => { refFn(el); if (el) { el.addEventListener('click', onTrigClick); el.addEventListener('keydown', onTrigKey); } };
878
827
  return (child && child.type)
879
- ? webjsx.createElement(child.type, { ...(child.props || {}), ref: refFn }, ...(child.children || []))
880
- : h('button', { type: 'button', class: 'ov-menubutton-trigger', ref: refFn }, child || 'Select');
828
+ ? webjsx.createElement(child.type, { ...(child.props || {}), ref: wireRef }, ...(child.children || []))
829
+ : h('button', { type: 'button', class: 'ov-menubutton-trigger', ref: wireRef }, child || 'Select');
881
830
  }
882
831
 
883
832
  // VideoLightbox — fullscreen video player overlay with backdrop dismiss.
package/src/components.js CHANGED
@@ -14,9 +14,9 @@ export {
14
14
  Panel, Card, Row, RowLink, PanelFromItems,
15
15
  Hero, HeroFromPageData, Marquee, Install, CliBlock, Receipt, Changelog,
16
16
  WorksList, WritingList, Manifesto, Section, PageHeader,
17
- Kpi, Sparkline, BarChart, Table, SearchInput, TextField, Select, EventList,
17
+ Kpi, Sparkline, BarChart, Table, HealthTable, ProcessRegistryTable, SearchInput, TextField, Select, EventList,
18
18
  HomeView, ProjectView, Form,
19
- Spinner, Skeleton, Alert, FilterPills, Avatar
19
+ Spinner, Skeleton, Alert, FilterPills, Avatar, avatarInitial
20
20
  } from './components/content.js';
21
21
 
22
22
  export {
@@ -114,4 +114,8 @@ export {
114
114
  skillLabel, getRecentPaths, saveRecentPath, renderChatMessages
115
115
  } from './components/freddie.js';
116
116
 
117
+ export {
118
+ makePage, api, loadingState, errorState, emptyState, refreshError
119
+ } from './components/freddie/runtime.js';
120
+
117
121
  export { mountCommunityApp } from './community-app.js';
@@ -385,6 +385,7 @@
385
385
  .ds-avatar-sm { width: 24px; height: 24px; font-size: 10px; }
386
386
  .ds-avatar-md { width: 36px; height: 36px; font-size: 12px; }
387
387
  .ds-avatar-lg { width: 48px; height: 48px; font-size: 15px; }
388
+ .ds-avatar-square { border-radius: var(--r-2, 6px); }
388
389
 
389
390
  /* SpreadsheetPreview — tabbed inline spreadsheet/CSV viewer (docstudio
390
391
  xls-preview.js port). Tabs reuse the same neutral-at-rest pattern as