anentrypoint-design 0.0.381 → 0.0.382

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/247420.css +228 -162
  2. package/dist/247420.js +24 -24
  3. package/package.json +1 -1
  4. package/src/components/content/avatar.js +31 -0
  5. package/src/components/content/charts.js +50 -0
  6. package/src/components/content/cli.js +46 -0
  7. package/src/components/content/feedback.js +77 -0
  8. package/src/components/content/fields.js +136 -0
  9. package/src/components/content/hero.js +146 -0
  10. package/src/components/content/lists.js +85 -0
  11. package/src/components/content/panel.js +79 -0
  12. package/src/components/content/row.js +115 -0
  13. package/src/components/content/table.js +97 -0
  14. package/src/components/content/views.js +81 -0
  15. package/src/components/content.js +29 -861
  16. package/src/components/editor-primitives/batch.js +60 -0
  17. package/src/components/editor-primitives/chrome.js +146 -0
  18. package/src/components/editor-primitives/collapse.js +46 -0
  19. package/src/components/editor-primitives/context-menu.js +127 -0
  20. package/src/components/editor-primitives/diagnostics.js +44 -0
  21. package/src/components/editor-primitives/focus-trap.js +26 -0
  22. package/src/components/editor-primitives/json-viewer.js +123 -0
  23. package/src/components/editor-primitives/layout.js +89 -0
  24. package/src/components/editor-primitives/modals.js +89 -0
  25. package/src/components/editor-primitives/pager.js +74 -0
  26. package/src/components/editor-primitives/property-grid.js +57 -0
  27. package/src/components/editor-primitives/shared.js +18 -0
  28. package/src/components/editor-primitives/split-panel.js +101 -0
  29. package/src/components/editor-primitives/toast.js +59 -0
  30. package/src/components/editor-primitives/tree.js +75 -0
  31. package/src/components/editor-primitives.js +35 -1048
  32. package/src/components/overlay-primitives/approval-prompt.js +38 -0
  33. package/src/components/overlay-primitives/auth-modal.js +89 -0
  34. package/src/components/overlay-primitives/command-palette.js +101 -0
  35. package/src/components/overlay-primitives/emoji-picker.js +103 -0
  36. package/src/components/overlay-primitives/floating.js +146 -0
  37. package/src/components/overlay-primitives/full-screen.js +45 -0
  38. package/src/components/overlay-primitives/menus.js +131 -0
  39. package/src/components/overlay-primitives/popover.js +51 -0
  40. package/src/components/overlay-primitives/roving-menu.js +73 -0
  41. package/src/components/overlay-primitives/settings-popover.js +85 -0
  42. package/src/components/overlay-primitives/tooltip.js +55 -0
  43. package/src/components/overlay-primitives.js +33 -855
  44. package/src/css/app-shell/chat-polish.css +39 -32
  45. package/src/css/app-shell/data-density.css +25 -21
  46. package/src/css/app-shell/files.css +37 -29
  47. package/src/css/app-shell/kits-appended.css +73 -50
  48. package/src/css/app-shell/responsive.css +47 -29
  49. package/src/kits/os/freddie-dashboard.css +2 -2
@@ -2,858 +2,36 @@
2
2
  // Shared positioning (auto-flip + viewport clamp) in useFloating; consumed by
3
3
  // all three. No inline styles except runtime left/top. CSS classes scoped to
4
4
  // .ds-247420 (see editor-primitives.css).
5
-
6
- import * as webjsx from '../../vendor/webjsx/index.js';
7
- import { Icon } from './shell.js';
8
- const h = webjsx.createElement;
9
- const kids = (c) => c == null ? [] : (Array.isArray(c) ? c : [c]);
10
- const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
11
-
12
- // Shared viewport-clamp margins (px). Previously scattered as bare 8/4/6
13
- // literals across useFloating + _clampToViewport. CLAMP_MARGIN is the gap a
14
- // fixed box keeps from the viewport edge; FLOAT_EDGE is the useFloating edge
15
- // gap; FLOAT_OFFSET_* are anchor-to-content offsets per overlay kind.
16
- const CLAMP_MARGIN = 8;
17
- const FLOAT_EDGE = 4;
18
- const FLOAT_OFFSET_TOOLTIP = 6;
19
- const FLOAT_OFFSET_POPOVER = 6;
20
- const FLOAT_OFFSET_DROPDOWN = 4;
21
-
22
- // useFloating compute left/top + auto-flip; re-runs on resize/scroll.
23
- export function useFloating(anchorEl, contentEl, { placement = 'bottom-start', offset = 8 } = {}) {
24
- if (!anchorEl || !contentEl) return { update() {}, dispose() {}, finalPlacement: placement };
25
- let finalPlacement = placement;
26
- const compute = () => {
27
- const a = anchorEl.getBoundingClientRect(), c = contentEl.getBoundingClientRect();
28
- const vw = window.innerWidth, vh = window.innerHeight;
29
- const [side, align = 'start'] = placement.split('-');
30
- let s = side;
31
- if (s === 'bottom' && a.bottom + offset + c.height > vh && a.top - offset - c.height >= 0) s = 'top';
32
- else if (s === 'top' && a.top - offset - c.height < 0 && a.bottom + offset + c.height <= vh) s = 'bottom';
33
- else if (s === 'right' && a.right + offset + c.width > vw && a.left - offset - c.width >= 0) s = 'left';
34
- else if (s === 'left' && a.left - offset - c.width < 0 && a.right + offset + c.width <= vw) s = 'right';
35
- let x = 0, y = 0;
36
- if (s === 'bottom' || s === 'top') {
37
- y = s === 'bottom' ? a.bottom + offset : a.top - offset - c.height;
38
- x = align === 'start' ? a.left : align === 'end' ? a.right - c.width : a.left + (a.width - c.width) / 2;
39
- } else {
40
- x = s === 'right' ? a.right + offset : a.left - offset - c.width;
41
- y = align === 'start' ? a.top : align === 'end' ? a.bottom - c.height : a.top + (a.height - c.height) / 2;
42
- }
43
- x = Math.max(FLOAT_EDGE, Math.min(vw - c.width - FLOAT_EDGE, x));
44
- y = Math.max(FLOAT_EDGE, Math.min(vh - c.height - FLOAT_EDGE, y));
45
- contentEl.style.left = x + 'px';
46
- contentEl.style.top = y + 'px';
47
- finalPlacement = s + '-' + align;
48
- };
49
- compute();
50
- const cb = () => compute();
51
- window.addEventListener('resize', cb);
52
- window.addEventListener('scroll', cb, true);
53
- // Reposition when the content box itself resizes (async-loaded content
54
- // grows the popover after initial positioning, pushing it off-viewport).
55
- const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(cb) : null;
56
- if (ro) ro.observe(contentEl);
57
- return {
58
- update: compute,
59
- dispose() { window.removeEventListener('resize', cb); window.removeEventListener('scroll', cb, true); if (ro) ro.disconnect(); },
60
- get finalPlacement() { return finalPlacement; }
61
- };
62
- }
63
-
64
- // useLongPress — fire callback after ms held without movement.
65
- export function useLongPress(targetEl, callback, { ms = 500 } = {}) {
66
- if (!targetEl) return () => {};
67
- let timer = null, sx = 0, sy = 0;
68
- const cancel = () => { if (timer) { clearTimeout(timer); timer = null; } };
69
- const onDown = (e) => { sx = e.clientX || 0; sy = e.clientY || 0; cancel(); timer = setTimeout(() => { timer = null; callback(e); }, ms); };
70
- const onMove = (e) => { if (!timer) return; const dx = (e.clientX || 0) - sx, dy = (e.clientY || 0) - sy; if (dx * dx + dy * dy > 64) cancel(); };
71
- const evts = [['pointerdown', onDown], ['pointermove', onMove], ['pointerup', cancel], ['pointerleave', cancel], ['pointercancel', cancel]];
72
- evts.forEach(([k, fn]) => targetEl.addEventListener(k, fn));
73
- return () => { cancel(); evts.forEach(([k, fn]) => targetEl.removeEventListener(k, fn)); };
74
- }
75
-
76
- // withBusy — run an async action with its triggering button disabled +
77
- // busy-labelled, so a double-click/double-tap can't fire it twice and the
78
- // user sees progress. Restores the button (label, disabled state,
79
- // aria-busy) when the action settles, including on throw. Re-entry while
80
- // already busy is dropped silently rather than queued. Mirrors docstudio's
81
- // dom-busy.js withButtonBusy — agentgui's app.js has no equivalent anywhere,
82
- // so every async-click handler (share/delete/retry/approve-deny) is
83
- // currently unguarded against rapid repeat clicks firing the same mutating
84
- // request twice.
85
- export async function withBusy(btn, fn, busyLabel = '...') {
86
- if (!btn) return fn();
87
- if (btn.disabled) return; // already in flight -> drop the repeat
88
- const prevHtml = btn.innerHTML;
89
- const prevDisabled = btn.disabled;
90
- btn.disabled = true;
91
- btn.setAttribute('aria-busy', 'true');
92
- if (busyLabel != null) btn.textContent = busyLabel;
93
- try {
94
- return await fn();
95
- } finally {
96
- btn.disabled = prevDisabled;
97
- btn.removeAttribute('aria-busy');
98
- btn.innerHTML = prevHtml;
99
- }
100
- }
101
-
102
- // Tooltip — single shared bubble appended to <body>.
103
- let _tipEl = null, _tipFloat = null, _tipTimer = null, _tipId = 0;
104
- function _hideTip() {
105
- if (_tipTimer) { clearTimeout(_tipTimer); _tipTimer = null; }
106
- if (_tipFloat) { _tipFloat.dispose(); _tipFloat = null; }
107
- if (_tipEl) { _tipEl.hidden = true; _tipEl.className = 'ds-tooltip'; }
108
- }
109
- // One module-scope scroll listener hides the shared bubble on any scroll —
110
- // registered once, never per-trigger (per-trigger leaked a listener per element).
111
- if (typeof window !== 'undefined' && !window.__dsTipScrollBound) {
112
- window.__dsTipScrollBound = true;
113
- window.addEventListener('scroll', _hideTip, true);
114
- }
115
- function _showTip(trigger, label, placement, kind) {
116
- if (typeof document === 'undefined') return;
117
- if (!_tipEl || !document.body.contains(_tipEl)) {
118
- _tipEl = document.createElement('div');
119
- _tipEl.className = 'ds-tooltip';
120
- _tipEl.setAttribute('role', 'tooltip');
121
- document.body.appendChild(_tipEl);
122
- }
123
- _tipEl.textContent = label;
124
- _tipEl.className = 'ds-tooltip kind-' + (kind || 'default');
125
- _tipEl.hidden = false;
126
- _tipEl.id = 'ds-tip-' + (++_tipId);
127
- trigger.setAttribute('aria-describedby', _tipEl.id);
128
- if (_tipFloat) _tipFloat.dispose();
129
- _tipFloat = useFloating(trigger, _tipEl, { placement, offset: FLOAT_OFFSET_TOOLTIP });
130
- }
131
-
132
- export function Tooltip({ children, label, placement = 'top', delay = 350, kind = 'default' } = {}) {
133
- const child = kids(children)[0];
134
- if (!child || !label) return child || null;
135
- const refFn = (el) => {
136
- if (!el || el._dsTip) return;
137
- el._dsTip = true;
138
- const schedule = () => { if (_tipTimer) clearTimeout(_tipTimer); _tipTimer = setTimeout(() => _showTip(el, label, placement, kind), delay); };
139
- const show = () => _showTip(el, label, placement, kind);
140
- el.addEventListener('pointerenter', schedule);
141
- el.addEventListener('pointerleave', _hideTip);
142
- el.addEventListener('focus', show);
143
- el.addEventListener('blur', _hideTip);
144
- el.addEventListener('keydown', (e) => { if (e.key === 'Escape') _hideTip(); });
145
- useLongPress(el, show, { ms: 500 });
146
- };
147
- const prevRef = child.props && child.props.ref;
148
- const wrap = (el) => { refFn(el); if (typeof prevRef === 'function') prevRef(el); };
149
- return webjsx.createElement(child.type, { ...(child.props || {}), ref: wrap }, ...(child.children || []));
150
- }
151
-
152
- // Popover — controlled, portaled to <body>.
153
- const _popovers = new WeakMap();
154
- export function Popover({ open, anchorEl, onClose, placement = 'bottom-start', children, ariaLabel } = {}) {
155
- if (typeof document === 'undefined') return null;
156
- const existing = anchorEl ? _popovers.get(anchorEl) : null;
157
- if (!open) {
158
- if (existing) { existing.dispose(); _popovers.delete(anchorEl); if (anchorEl && anchorEl.focus) anchorEl.focus(); }
159
- return null;
160
- }
161
- if (existing || !anchorEl) return null;
162
- const el = document.createElement('div');
163
- el.className = 'ds-popover';
164
- el.setAttribute('role', 'dialog');
165
- el.setAttribute('aria-modal', 'true');
166
- if (ariaLabel) el.setAttribute('aria-label', ariaLabel);
167
- el.tabIndex = -1;
168
- document.body.appendChild(el);
169
- webjsx.applyDiff(el, h('div', { class: 'ds-popover-inner' }, ...kids(children)));
170
- const floating = useFloating(anchorEl, el, { placement, offset: FLOAT_OFFSET_POPOVER });
171
- const close = () => onClose && onClose();
172
- const onDown = (e) => { if (el.contains(e.target) || anchorEl.contains(e.target)) return; close(); };
173
- const onKey = (e) => {
174
- if (e.key === 'Escape') { e.preventDefault(); close(); return; }
175
- if (e.key !== 'Tab') return;
176
- const nodes = el.querySelectorAll(FOCUSABLE_SEL); if (!nodes.length) { e.preventDefault(); return; }
177
- const first = nodes[0], last = nodes[nodes.length - 1], a = document.activeElement;
178
- if (e.shiftKey && a === first) { e.preventDefault(); last.focus(); }
179
- else if (!e.shiftKey && a === last) { e.preventDefault(); first.focus(); }
180
- };
181
- el.addEventListener('keydown', onKey);
182
- document.addEventListener('mousedown', onDown, true);
183
- // setTimeout(0), not queueMicrotask — see _anchoredOverlayLifecycle's
184
- // comment: the opening click's own default focus-on-click can otherwise
185
- // win the race and leave focus outside el, breaking Escape/Tab-trap.
186
- setTimeout(() => { const f = el.querySelector(FOCUSABLE_SEL); (f || el).focus(); }, 0);
187
- _popovers.set(anchorEl, { dispose() {
188
- document.removeEventListener('mousedown', onDown, true);
189
- floating.dispose();
190
- if (el.parentNode) el.parentNode.removeChild(el);
191
- }});
192
- return null;
193
- }
194
-
195
- // useRovingMenu — the shared open/close/outside-click/roving-nav/typeahead
196
- // state machine behind Dropdown, PermissionMenu, and MenuButton. All three
197
- // previously reimplemented an identical ~70-line skeleton (byte-identical
198
- // close() teardown, near-identical onMenuKey); this factors it into one
199
- // place so a fix/feature (e.g. typeahead) lands for every consumer instead
200
- // of drifting per-copy. `itemSelector` picks the live focusable items inside
201
- // the rendered menu (each consumer uses a different role: menuitem /
202
- // menuitemcheckbox / menuitemradio); `getLabel(item)` + `items` enable
203
- // typeahead when `typeahead` is true (Dropdown/MenuButton have it,
204
- // PermissionMenu's categories aren't typically typeahead-searched so it
205
- // defaults off but can opt in). Returns { refFn, onTrigClick, onTrigKey,
206
- // openMenu, close, focusItem, isOpen } — the caller still owns rendering the
207
- // menu's DOM/CSS (role/class per consumer stays distinct) and wires
208
- // `menuEl.addEventListener('keydown', onMenuKey)` itself via the returned
209
- // `onMenuKey`, since only the caller knows when its menuEl exists.
210
- export function useRovingMenu({ itemSelector, items = [], getLabel = (it) => it.label, typeahead = false, placement = 'bottom-start', onOpenChange } = {}) {
211
- let triggerEl = null, open = false, menuEl = null, floating = null, typeBuf = '', typeTimer = null;
212
- const liveItems = () => menuEl ? [...menuEl.querySelectorAll(itemSelector)] : [];
213
- const focusItem = (idx) => { const b = liveItems(); if (!b.length) return; b[((idx % b.length) + b.length) % b.length].focus(); };
214
- const onDown = (e) => { if (menuEl && menuEl.contains(e.target)) return; if (triggerEl && triggerEl.contains(e.target)) return; close(false); };
215
- const close = (restore = true) => {
216
- if (!open) return; open = false;
217
- if (floating) { floating.dispose(); floating = null; }
218
- if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl);
219
- menuEl = null;
220
- document.removeEventListener('mousedown', onDown, true);
221
- if (triggerEl) triggerEl.setAttribute('aria-expanded', 'false');
222
- if (restore && triggerEl) triggerEl.focus();
223
- if (onOpenChange) onOpenChange(false);
224
- };
225
- const onMenuKey = (e) => {
226
- const b = liveItems(), idx = b.indexOf(document.activeElement);
227
- if (e.key === 'Escape') { e.preventDefault(); close(); }
228
- else if (e.key === 'ArrowDown') { e.preventDefault(); focusItem(idx + 1); }
229
- else if (e.key === 'ArrowUp') { e.preventDefault(); focusItem(idx - 1); }
230
- else if (e.key === 'Home') { e.preventDefault(); focusItem(0); }
231
- else if (e.key === 'End') { e.preventDefault(); focusItem(b.length - 1); }
232
- else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (idx >= 0) b[idx].click(); }
233
- else if (typeahead && e.key.length === 1 && /\S/.test(e.key)) {
234
- typeBuf += e.key.toLowerCase();
235
- if (typeTimer) clearTimeout(typeTimer);
236
- typeTimer = setTimeout(() => { typeBuf = ''; }, 600);
237
- const selectable = items.filter(it => !it.separator && !it.disabled && !it.unavailable);
238
- const m = selectable.findIndex(it => (getLabel(it) || '').toLowerCase().startsWith(typeBuf));
239
- if (m >= 0) focusItem(m);
240
- }
241
- };
242
- const openMenu = (buildMenuEl, focusFirst = true) => {
243
- if (open || !triggerEl) return;
244
- open = true;
245
- menuEl = buildMenuEl();
246
- menuEl.tabIndex = -1;
247
- document.body.appendChild(menuEl);
248
- menuEl.addEventListener('keydown', onMenuKey);
249
- floating = useFloating(triggerEl, menuEl, { placement, offset: FLOAT_OFFSET_DROPDOWN });
250
- document.addEventListener('mousedown', onDown, true);
251
- triggerEl.setAttribute('aria-expanded', 'true');
252
- if (focusFirst && liveItems().length) queueMicrotask(() => focusItem(0));
253
- if (onOpenChange) onOpenChange(true);
254
- };
255
- const onTrigClick = (buildMenuEl) => { if (open) close(false); else openMenu(buildMenuEl, true); };
256
- const onTrigKey = (e, buildMenuEl) => { if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!open) openMenu(buildMenuEl, true); else focusItem(0); } };
257
- const refFn = (dsFlag) => (el) => {
258
- if (!el || el[dsFlag]) return;
259
- el[dsFlag] = true; triggerEl = el;
260
- el.setAttribute('aria-haspopup', 'menu');
261
- el.setAttribute('aria-expanded', 'false');
262
- };
263
- return { refFn, onTrigClick, onTrigKey, openMenu, close, focusItem, isOpen: () => open };
264
- }
265
-
266
- // Dropdown — button trigger + portaled menu.
267
- export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-start', ariaLabel } = {}) {
268
- const menu = useRovingMenu({ itemSelector: '[role="menuitem"]:not([aria-disabled="true"])', items, typeahead: true, placement });
269
- const select = (it) => { if (it.disabled || it.separator) return; if (onSelect) onSelect(it.id, it); menu.close(); };
270
- const buildMenuEl = () => {
271
- const el = document.createElement('div');
272
- el.className = 'ds-popover ds-dropdown-menu';
273
- el.setAttribute('role', 'menu');
274
- if (ariaLabel) el.setAttribute('aria-label', ariaLabel);
275
- const tree = h('div', { class: 'ds-dropdown-list' },
276
- ...items.map((it, i) => it.separator
277
- ? h('div', { key: 'sep' + i, class: 'ds-dropdown-separator', role: 'separator' })
278
- : h('button', {
279
- key: it.id || i, type: 'button', role: 'menuitem',
280
- class: 'ds-dropdown-item' + (it.danger ? ' is-danger' : ''),
281
- 'aria-disabled': it.disabled ? 'true' : 'false',
282
- tabindex: '-1', onclick: () => select(it),
283
- },
284
- it.glyph != null ? h('span', { class: 'ds-dropdown-glyph', 'aria-hidden': 'true' }, it.glyph) : null,
285
- h('span', { class: 'ds-dropdown-label' }, it.label)
286
- )));
287
- webjsx.applyDiff(el, tree);
288
- return el;
289
- };
290
- const onTrigClick = () => menu.onTrigClick(buildMenuEl);
291
- const onTrigKey = (e) => menu.onTrigKey(e, buildMenuEl);
292
- const refFn = menu.refFn('_dsDropdown');
293
- const child = (typeof trigger === 'function') ? trigger() : trigger;
294
- const wireRef = (el) => { refFn(el); if (el) { el.addEventListener('click', onTrigClick); el.addEventListener('keydown', onTrigKey); } };
295
- return (child && child.type)
296
- ? webjsx.createElement(child.type, { ...(child.props || {}), ref: wireRef }, ...(child.children || []))
297
- : h('button', { type: 'button', class: 'ds-dropdown-trigger', ref: wireRef }, child || 'Menu');
298
- }
299
-
300
- // PermissionMenu — a role=menu of role=menuitemcheckbox rows, one per
301
- // category, with roving tabindex + Arrow-up/down/Home/End navigation and
302
- // Escape-closes-and-restores-focus, plus "Approve all"/"Revoke all" actions.
303
- // Mirrors Dropdown's own open/close + outside-click wiring (a portaled menu
304
- // element, a document-level mousedown listener, focus restored to the
305
- // trigger on close) rather than reimplementing that plumbing.
306
- export function PermissionMenu({ trigger, categories = [], approved = [], onToggle, onToggleAll, placement = 'bottom-start', ariaLabel = 'Permissions' } = {}) {
307
- const isApproved = (id) => approved.indexOf(id) !== -1;
308
- const menu = useRovingMenu({ itemSelector: '[role="menuitemcheckbox"]', items: categories, getLabel: (cat) => cat.label || cat.id, typeahead: true, placement });
309
- const toggle = (cat) => { if (onToggle) onToggle(cat.id, !isApproved(cat.id)); };
310
- const buildMenuEl = () => {
311
- const el = document.createElement('div');
312
- el.className = 'ds-popover ov-perm-menu';
313
- el.setAttribute('role', 'menu');
314
- el.setAttribute('aria-label', ariaLabel);
315
- const rows = categories.map((cat, i) => h('button', {
316
- key: cat.id || i, type: 'button', role: 'menuitemcheckbox',
317
- 'aria-checked': isApproved(cat.id) ? 'true' : 'false',
318
- class: 'ov-perm-item' + (isApproved(cat.id) ? ' is-approved' : ''),
319
- tabindex: '-1',
320
- onclick: () => toggle(cat),
321
- }, h('span', { class: 'ov-perm-label' }, cat.label || cat.id)));
322
- const actionsRow = h('div', { class: 'ov-perm-actions' },
323
- h('button', { type: 'button', class: 'ov-perm-action', onclick: () => onToggleAll && onToggleAll(true) }, 'Approve all'),
324
- h('button', { type: 'button', class: 'ov-perm-action', onclick: () => onToggleAll && onToggleAll(false) }, 'Revoke all'));
325
- webjsx.applyDiff(el, h('div', { class: 'ov-perm-list' }, ...rows, actionsRow));
326
- return el;
327
- };
328
- const onTrigClick = () => menu.onTrigClick(buildMenuEl);
329
- const onTrigKey = (e) => menu.onTrigKey(e, buildMenuEl);
330
- const refFn = menu.refFn('_dsPermMenu');
331
- const child = (typeof trigger === 'function') ? trigger() : trigger;
332
- const wireRef = (el) => { refFn(el); if (el) { el.addEventListener('click', onTrigClick); el.addEventListener('keydown', onTrigKey); } };
333
- return (child && child.type)
334
- ? webjsx.createElement(child.type, { ...(child.props || {}), ref: wireRef }, ...(child.children || []))
335
- : h('button', { type: 'button', class: 'ov-perm-trigger', ref: wireRef }, child || 'Permissions');
336
- }
337
-
338
- // ApprovalPrompt — an inline, in-thread tool-permission card (as opposed to
339
- // PermissionMenu's settings-style dropdown): shows the tool name + an
340
- // optional args preview, an optional free-text note the user can attach to
341
- // their decision (auto-focused, since the note is usually the primary
342
- // reason to open this card at all), and up to four resolution actions
343
- // (once/session/all/deny). Mirrors docstudio's chat-approval-prompts.js
344
- // buildApprovalPrompt shape. The note textarea is entirely optional -
345
- // omitting `onDecision`'s use of the note arg keeps existing simpler
346
- // once/deny-only call sites unaffected.
347
- export function ApprovalPrompt({ toolName, categoryLabel, argsPreview, onDecision, autoFocusNote = true } = {}) {
348
- let noteEl = null;
349
- const noteRef = (el) => {
350
- if (!el || noteEl === el) return;
351
- noteEl = el;
352
- if (autoFocusNote) queueMicrotask(() => noteEl && noteEl.focus());
353
- };
354
- const decide = (kind) => { if (onDecision) onDecision(kind, (noteEl && noteEl.value || '').trim()); };
355
- return h('div', { class: 'ov-approval', role: 'group', 'aria-label': toolName ? `Permission requested: ${toolName}` : 'Permission requested' },
356
- h('div', { class: 'ov-approval-head' },
357
- h('span', { class: 'ov-approval-icon' }, Icon('lock', { size: 16 })),
358
- h('strong', { class: 'ov-approval-tool' }, toolName || ''),
359
- categoryLabel ? h('span', { class: 'ov-approval-cat' }, '- ' + categoryLabel) : null),
360
- argsPreview ? h('pre', { class: 'ov-approval-args' }, argsPreview) : null,
361
- h('textarea', {
362
- class: 'ov-approval-note', ref: noteRef,
363
- placeholder: 'Add instructions for the assistant (optional)...',
364
- }),
365
- h('div', { class: 'ov-approval-actions' },
366
- h('button', { type: 'button', class: 'ov-approval-btn ov-approval-btn-primary', onclick: () => decide('once') }, 'Allow once'),
367
- h('button', { type: 'button', class: 'ov-approval-btn ov-approval-btn-soft', onclick: () => decide('session') }, 'Allow for session'),
368
- h('button', { type: 'button', class: 'ov-approval-btn', onclick: () => decide('all') }, 'Allow all'),
369
- h('button', { type: 'button', class: 'ov-approval-btn ov-approval-btn-deny', onclick: () => decide('deny') }, 'Deny')));
370
- }
371
-
372
- // Clamp a fixed-position box to the viewport given desired top-left coords.
373
- function _clampToViewport(x, y, w, h, margin = CLAMP_MARGIN) {
374
- const vw = (typeof window !== 'undefined' ? window.innerWidth : 1024);
375
- const vh = (typeof window !== 'undefined' ? window.innerHeight : 768);
376
- return {
377
- left: Math.max(margin, Math.min(vw - w - margin, x)),
378
- top: Math.max(margin, Math.min(vh - h - margin, y)),
379
- };
380
- }
381
-
382
- // Tab focus trap for a dialog root — keeps Tab/Shift+Tab cycling inside `el`.
383
- // Call from an onkeydown handler; returns true if it handled the event.
384
- export function trapTab(el, e) {
385
- if (e.key !== 'Tab') return false;
386
- const nodes = el.querySelectorAll(FOCUSABLE_SEL);
387
- if (!nodes.length) { e.preventDefault(); return true; }
388
- const first = nodes[0], last = nodes[nodes.length - 1], a = document.activeElement;
389
- if (e.shiftKey && a === first) { e.preventDefault(); last.focus(); return true; }
390
- if (!e.shiftKey && a === last) { e.preventDefault(); first.focus(); return true; }
391
- return false;
392
- }
393
-
394
- // Shared lifecycle for fixed anchor-positioned popovers (EmojiPicker,
395
- // SettingsPopover): on mount, place+clamp near (anchorX, anchorY), focus the
396
- // root, and wire an outside-mousedown close. Returns a cleanup fn the ref(null)
397
- // branch must call. Both consumers deduped through this so the
398
- // queueMicrotask/place/clamp/outside-close dance is authored once.
399
- function _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW, fallbackH, close }) {
400
- const place = () => {
401
- const r = el.getBoundingClientRect();
402
- const { left, top } = _clampToViewport(anchorX, anchorY, r.width || fallbackW, r.height || fallbackH);
403
- el.style.left = left + 'px'; el.style.top = top + 'px';
404
- };
405
- // setTimeout(0), not queueMicrotask: the triggering click's own default
406
- // focus-on-click (moving focus to the clicked <button>) can run AFTER a
407
- // same-tick microtask, so a queueMicrotask focus() call here was losing
408
- // the race and leaving focus on the trigger button instead of the
409
- // dialog -- breaking Escape-to-close (keydown only bubbles from the
410
- // focused element) for any keyboard user. A macrotask reliably runs
411
- // after the click's focus settles.
412
- setTimeout(() => { place(); el.focus(); }, 0);
413
- const onDown = (e) => { if (!el.contains(e.target)) close(); };
414
- queueMicrotask(() => document.addEventListener('mousedown', onDown, true));
415
- return () => document.removeEventListener('mousedown', onDown, true);
416
- }
417
-
418
- // CommandPalette — centered Cmd+K palette with live filter + keyboard nav.
419
- export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
420
- if (!open) return null;
421
- const list = Array.isArray(items) ? items : [];
422
- const labelOf = (it) => String(it.label || it.title || it.name || '');
423
- let active = 0, filterText = '';
424
-
425
- const matches = () => {
426
- const q = filterText.trim().toLowerCase();
427
- return q ? list.filter(it => labelOf(it).toLowerCase().includes(q)) : list.slice();
428
- };
429
-
430
- const rowsFor = (filtered) => {
431
- const out = [];
432
- let lastGroup = null, flatIdx = 0;
433
- for (const it of filtered) {
434
- const grp = it.group != null ? String(it.group) : null;
435
- if (grp && grp !== lastGroup) {
436
- out.push(h('div', { class: 'ov-cmd-group', role: 'presentation' }, grp));
437
- lastGroup = grp;
438
- }
439
- const idx = flatIdx++;
440
- const glyph = it.icon != null ? it.icon : (it.glyph != null ? it.glyph : null);
441
- const hint = it.hint != null ? it.hint : (it.shortcut != null ? it.shortcut : null);
442
- out.push(h('button', {
443
- type: 'button', role: 'option',
444
- id: 'ov-cmd-item-' + idx,
445
- 'data-idx': String(idx),
446
- 'aria-selected': idx === active ? 'true' : 'false',
447
- class: 'ov-cmd-item' + (idx === active ? ' is-active' : ''),
448
- onclick: () => choose(it),
449
- onmousemove: () => { if (active !== idx) { active = idx; renderInner(); } },
450
- },
451
- glyph != null ? h('span', { class: 'ov-cmd-glyph', 'aria-hidden': 'true' }, glyph) : null,
452
- h('span', { class: 'ov-cmd-label' }, labelOf(it)),
453
- hint != null ? h('span', { class: 'ov-cmd-hint' }, hint) : null
454
- ));
455
- }
456
- return out;
457
- };
458
-
459
- let rootEl = null, inputEl = null, listEl = null, flat = [];
460
- // Remember the element focused before the palette opened so we can return
461
- // focus there on close (the input steals focus on mount).
462
- const prevFocus = (typeof document !== 'undefined') ? document.activeElement : null;
463
- const restoreFocus = () => { if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus(); };
464
- const close = () => { restoreFocus(); if (onClose) onClose(); };
465
- const choose = (it) => { if (it && onSelect) onSelect(it); };
466
-
467
- const renderInner = () => {
468
- if (!listEl) return;
469
- const filtered = matches();
470
- flat = filtered;
471
- if (active >= filtered.length) active = Math.max(0, filtered.length - 1);
472
- webjsx.applyDiff(listEl, h('div', { class: 'ov-cmd-list-inner' },
473
- filtered.length ? rowsFor(filtered) : h('div', { class: 'ov-cmd-empty' }, 'No results')));
474
- const sel = listEl.querySelector('.ov-cmd-item.is-active');
475
- if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' });
476
- if (inputEl) inputEl.setAttribute('aria-activedescendant', filtered.length ? 'ov-cmd-item-' + active : '');
477
- };
478
-
479
- const onKey = (e) => {
480
- if (e.key === 'Escape') { e.preventDefault(); close(); }
481
- else if (e.key === 'ArrowDown') { e.preventDefault(); if (flat.length) { active = (active + 1) % flat.length; renderInner(); } }
482
- else if (e.key === 'ArrowUp') { e.preventDefault(); if (flat.length) { active = (active - 1 + flat.length) % flat.length; renderInner(); } }
483
- else if (e.key === 'Enter') { e.preventDefault(); if (flat[active]) choose(flat[active]); }
484
- };
485
-
486
- return h('div', {
487
- class: 'ov-cmd-backdrop', role: 'presentation',
488
- ref: (el) => {
489
- if (!el || el._ovCmd) return; el._ovCmd = true; rootEl = el;
490
- el.addEventListener('mousedown', (e) => {
491
- const panel = el.querySelector('.ov-cmd-panel');
492
- if (panel && !panel.contains(e.target)) close();
493
- });
494
- },
495
- },
496
- h('div', { class: 'ov-cmd-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette', onkeydown: onKey },
497
- h('input', {
498
- type: 'text', class: 'ov-cmd-input', placeholder: 'Type a command…',
499
- 'aria-label': 'command search',
500
- role: 'combobox',
501
- 'aria-autocomplete': 'list',
502
- 'aria-expanded': 'true',
503
- 'aria-controls': 'ov-cmd-list',
504
- 'aria-activedescendant': '',
505
- oninput: (e) => { filterText = e.target.value; active = 0; renderInner(); },
506
- ref: (el) => { if (!el || el._ovCmdIn) return; el._ovCmdIn = true; inputEl = el; queueMicrotask(() => el.focus()); },
507
- }),
508
- h('div', { class: 'ov-cmd-list', id: 'ov-cmd-list', role: 'listbox',
509
- ref: (el) => { if (!el) return; listEl = el; queueMicrotask(renderInner); } })
510
- )
511
- );
512
- }
513
-
514
- // Sanctioned literal-emoji exception: an emoji picker's whole purpose is to
515
- // present emoji, so the glyph ban does not apply to this data table or the
516
- // per-emoji <button> labels below. This is intentional product content, not
517
- // decorative chrome.
518
- const EMOJI_CATEGORIES = [
519
- { id: 'smileys', label: '😀', emoji: [
520
- ['😀', 'grinning smile'], ['😁', 'grinning smile happy'], ['😂', 'joy tears laugh'], ['🤣', 'rofl laugh'],
521
- ['😊', 'smile blush happy'], ['😍', 'heart eyes love'], ['😘', 'kiss'], ['😎', 'cool sunglasses'],
522
- ['🤔', 'thinking'], ['😅', 'sweat smile'], ['😉', 'wink'], ['🙂', 'smile slight'],
523
- ['😇', 'angel innocent'], ['🥳', 'party'], ['😴', 'sleep'], ['🤩', 'starstruck'],
524
- ['😜', 'wink tongue'], ['😢', 'cry sad'], ['😭', 'sob cry'], ['😡', 'angry mad'],
525
- ['😱', 'scream shock'], ['🥺', 'pleading'], ['😤', 'huff'], ['😬', 'grimace'],
526
- ] },
527
- { id: 'gestures', label: '👍', emoji: [
528
- ['👍', 'thumbsup yes good'], ['👎', 'thumbsdown no bad'], ['👌', 'ok'], ['✌️', 'peace'],
529
- ['🤞', 'fingers crossed'], ['🙏', 'pray thanks'], ['👏', 'clap'], ['🙌', 'raised hands'],
530
- ['💪', 'muscle strong'], ['👀', 'eyes look'], ['🤝', 'handshake'], ['✋', 'hand stop'],
531
- ['🤙', 'call'], ['👋', 'wave hi bye'], ['🤟', 'love you'], ['☝️', 'point up'],
532
- ] },
533
- { id: 'hearts', label: '❤️', emoji: [
534
- ['❤️', 'heart love red'], ['🧡', 'heart orange'], ['💛', 'heart yellow'], ['💚', 'heart green'],
535
- ['💙', 'heart blue'], ['💜', 'heart purple'], ['🖤', 'heart black'], ['🤍', 'heart white'],
536
- ['💔', 'broken heart'], ['💕', 'hearts'], ['💖', 'sparkling heart'], ['💗', 'growing heart'],
537
- ] },
538
- { id: 'symbols', label: '✅', emoji: [
539
- ['🔥', 'fire lit'], ['💯', 'hundred'], ['✅', 'check yes done'], ['❌', 'cross no'],
540
- ['⭐', 'star'], ['🎉', 'party tada'], ['🎊', 'confetti'], ['✨', 'sparkles'],
541
- ['💡', 'idea lightbulb'], ['⚡', 'zap lightning'], ['💢', 'anger'], ['💀', 'skull dead'],
542
- ['🚀', 'rocket launch'], ['🏆', 'trophy win'],
543
- ] },
544
- ];
545
- const ALL_EMOJI = EMOJI_CATEGORIES.flatMap((c) => c.emoji);
546
-
547
- // EmojiPicker — fixed popover near (anchorX, anchorY) with category tabs + grid.
548
- // `query`, when non-empty, filters across all categories by name/keyword
549
- // substring match (case-insensitive) instead of showing the active tab.
550
- export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose, query = '' } = {}) {
551
- if (!open) return null;
552
- let cat = EMOJI_CATEGORIES[0].id;
553
- let rootEl = null, gridEl = null;
554
- const close = () => onClose && onClose();
555
-
556
- const renderGrid = () => {
557
- if (!gridEl) return;
558
- const q = (query || '').trim().toLowerCase();
559
- const cells = q
560
- ? ALL_EMOJI.filter(([, name]) => name.toLowerCase().includes(q))
561
- : (EMOJI_CATEGORIES.find(x => x.id === cat) || EMOJI_CATEGORIES[0]).emoji;
562
- webjsx.applyDiff(gridEl, h('div', { class: 'ov-emoji-grid-inner' },
563
- cells.length ? cells.map(([ch, name]) => h('button', {
564
- type: 'button', class: 'ov-emoji-cell', 'aria-label': name || ch, title: name || ch,
565
- onclick: () => { if (onSelect) onSelect(ch); },
566
- }, ch)) : h('div', { class: 'ov-emoji-empty' }, 'no emoji found')));
567
- };
568
-
569
- const tabNavKey = (e) => {
570
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
571
- const tabs = rootEl ? [...rootEl.querySelectorAll('.ov-emoji-tab')] : [];
572
- if (!tabs.length) return;
573
- const idx = tabs.indexOf(document.activeElement);
574
- if (idx < 0) return;
575
- e.preventDefault();
576
- const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
577
- tabs[next].focus();
578
- tabs[next].click();
579
- };
580
-
581
- return h('div', {
582
- class: 'ov-emoji-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Emoji picker',
583
- tabindex: '-1',
584
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } tabNavKey(e); if (rootEl) trapTab(rootEl, e); },
585
- ref: (el) => {
586
- if (!el) { if (rootEl && rootEl._ovEmojiCleanup) rootEl._ovEmojiCleanup(); return; }
587
- if (el._ovEmoji) return; el._ovEmoji = true; rootEl = el;
588
- el._ovEmojiCleanup = _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW: 260, fallbackH: 240, close });
589
- },
590
- },
591
- (query || '').trim() ? null : h('div', { class: 'ov-emoji-tabs', role: 'tablist' },
592
- ...EMOJI_CATEGORIES.map((c) => h('button', {
593
- type: 'button', class: 'ov-emoji-tab', role: 'tab',
594
- 'aria-selected': c.id === cat ? 'true' : 'false',
595
- 'aria-controls': 'ov-emoji-panel',
596
- onclick: (e) => {
597
- cat = c.id;
598
- const tabs = rootEl.querySelectorAll('.ov-emoji-tab');
599
- tabs.forEach(t => t.setAttribute('aria-selected', 'false'));
600
- e.currentTarget.setAttribute('aria-selected', 'true');
601
- renderGrid();
602
- },
603
- }, c.label))),
604
- h('div', {
605
- class: 'ov-emoji-grid', id: 'ov-emoji-panel', role: 'tabpanel',
606
- 'aria-label': EMOJI_CATEGORIES.find(c => c.id === cat)?.label || EMOJI_CATEGORIES[0].label,
607
- ref: (el) => { if (!el) return; gridEl = el; queueMicrotask(renderGrid); } })
608
- );
609
- }
610
-
611
- // BootOverlay — full-screen brand/progress overlay with error state.
612
- export function BootOverlay({ progress = 0, phase = '', errored = false, visible = false } = {}) {
613
- if (!visible) return null;
614
- let pct = Number(progress) || 0;
615
- if (pct <= 1) pct = pct * 100;
616
- pct = Math.max(0, Math.min(100, pct));
617
- return h('div', { class: 'ov-boot' + (errored ? ' is-error' : ''), role: errored ? 'alert' : 'status', 'aria-live': 'polite' },
618
- h('div', { class: 'ov-boot-inner' },
619
- errored
620
- ? h('div', { class: 'ov-boot-mark ov-boot-mark-error', 'aria-hidden': 'true' }, Icon('warn'))
621
- : h('div', { class: 'ov-boot-spinner', 'aria-hidden': 'true' }),
622
- !errored ? h('div', { class: 'ov-boot-bar', role: 'progressbar',
623
- 'aria-valuenow': String(Math.round(pct)), 'aria-valuemin': '0', 'aria-valuemax': '100' },
624
- h('div', { class: 'ov-boot-bar-fill', style: 'width:' + pct + '%' })) : null,
625
- h('div', { class: 'ov-boot-phase' }, String(phase || (errored ? 'Error' : 'Loading…')))
626
- )
627
- );
628
- }
629
-
630
- // SettingsPopover — fixed popover with generic section/row control rendering.
631
- export function SettingsPopover({ title = 'Settings', open, anchorX = 0, anchorY = 0, sections = [], onClose } = {}) {
632
- if (!open) return null;
633
- let rootEl = null;
634
- const close = () => onClose && onClose();
635
- const secs = Array.isArray(sections) ? sections : [];
636
-
637
- const renderRow = (row, i) => {
638
- const label = row.label != null ? row.label : (row.title != null ? row.title : '');
639
- const kind = row.kind;
640
- // Give every interactive control a stable id and point the row label's
641
- // `for` at it, so the visible label is the control's accessible name.
642
- const ctrlId = 'ov-set-' + i + '-' + kind;
643
- const labelNode = h('label', { class: 'ov-set-row-label', for: ctrlId }, String(label));
644
- let control = null;
645
- if (kind === 'select') {
646
- const opts = Array.isArray(row.options) ? row.options : [];
647
- // Controlled via the `value` prop only — per-option `selected` is
648
- // dropped so the two don't fight (value wins).
649
- control = h('select', {
650
- id: ctrlId,
651
- class: 'ov-set-control', value: row.value != null ? String(row.value) : undefined,
652
- onchange: (e) => row.onChange && row.onChange(e.target.value),
653
- }, ...opts.map(o => {
654
- const v = (o && typeof o === 'object') ? o.value : o;
655
- const l = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;
656
- return h('option', { value: String(v) }, String(l));
657
- }));
658
- } else if (kind === 'toggle') {
659
- control = h('input', {
660
- id: ctrlId,
661
- type: 'checkbox', class: 'ov-set-toggle',
662
- checked: row.value ? 'checked' : undefined,
663
- onchange: (e) => row.onChange && row.onChange(e.target.checked),
664
- });
665
- } else if (kind === 'range') {
666
- control = h('input', {
667
- id: ctrlId,
668
- type: 'range', class: 'ov-set-control',
669
- min: String(row.min != null ? row.min : 0),
670
- max: String(row.max != null ? row.max : 100),
671
- step: String(row.step != null ? row.step : 1),
672
- value: String(row.value != null ? row.value : 0),
673
- oninput: (e) => row.onChange && row.onChange(Number(e.target.value)),
674
- });
675
- } else if (kind === 'button') {
676
- control = h('button', { type: 'button', class: 'ov-set-btn',
677
- onclick: () => row.onClick && row.onClick() }, String(label || 'Action'));
678
- return h('div', { class: 'ov-set-row', key: i }, control);
679
- } else {
680
- control = h('span', { class: 'ov-set-row-value' }, String(row.value != null ? row.value : ''));
681
- // Non-interactive value row: a plain span label (no `for` target).
682
- return h('div', { class: 'ov-set-row', key: i }, h('span', { class: 'ov-set-row-label' }, String(label)), control);
683
- }
684
- return h('div', { class: 'ov-set-row', key: i }, labelNode, control);
685
- };
686
-
687
- return h('div', {
688
- class: 'ov-set-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': String(title), tabindex: '-1',
689
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) trapTab(rootEl, e); },
690
- ref: (el) => {
691
- if (!el) { if (rootEl && rootEl._ovSetCleanup) rootEl._ovSetCleanup(); return; }
692
- if (el._ovSet) return; el._ovSet = true; rootEl = el;
693
- el._ovSetCleanup = _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW: 280, fallbackH: 200, close });
694
- },
695
- },
696
- h('div', { class: 'ov-set-head' }, String(title)),
697
- h('div', { class: 'ov-set-body' },
698
- ...secs.map((sec, si) => {
699
- const slabel = sec.label != null ? sec.label : (sec.title != null ? sec.title : '');
700
- const rows = Array.isArray(sec.rows) ? sec.rows : (Array.isArray(sec.items) ? sec.items : []);
701
- return h('div', { class: 'ov-set-section', key: si },
702
- slabel ? h('div', { class: 'ov-set-section-head' }, String(slabel)) : null,
703
- ...rows.map((r, ri) => renderRow(r, ri)));
704
- }))
705
- );
706
- }
707
-
708
- // AuthModal — centered login dialog: extension / generate / import (nsec) modes.
709
- export function AuthModal({ mode = 'extension', error = '', busy = false, open = false, onModeChange, onConnectExtension, onGenerate, onImport, onClose } = {}) {
710
- if (!open) return null;
711
- const close = () => onClose && onClose();
712
- const modes = [
713
- { id: 'extension', label: 'Extension' },
714
- { id: 'generate', label: 'Generate' },
715
- { id: 'import', label: 'Import key' },
716
- ];
717
- let nsec = '';
718
- const body = () => {
719
- if (mode === 'generate') {
720
- return [
721
- h('p', { class: 'ov-auth-hint' }, 'Create a fresh Nostr identity. Back up the key after.'),
722
- h('button', { type: 'button', class: 'ov-auth-primary', disabled: busy ? true : null,
723
- onclick: () => onGenerate && onGenerate() }, busy ? 'Working…' : 'Generate new key'),
724
- ];
725
- }
726
- if (mode === 'import') {
727
- return [
728
- h('p', { class: 'ov-auth-hint' }, 'Paste an existing nsec / hex secret key.'),
729
- h('input', {
730
- type: 'password', class: 'ov-auth-input', placeholder: 'nsec1…',
731
- 'aria-label': 'secret key', disabled: busy ? true : null,
732
- oninput: (e) => { nsec = e.target.value; },
733
- onkeydown: (e) => { if (e.key === 'Enter') { e.preventDefault(); onImport && onImport(nsec); } },
734
- }),
735
- h('button', { type: 'button', class: 'ov-auth-primary', disabled: busy ? true : null,
736
- onclick: () => onImport && onImport(nsec) }, busy ? 'Working…' : 'Import'),
737
- ];
738
- }
739
- return [
740
- h('p', { class: 'ov-auth-hint' }, 'Connect a NIP-07 browser extension (Alby, nos2x…).'),
741
- h('button', { type: 'button', class: 'ov-auth-primary', disabled: busy ? true : null,
742
- onclick: () => onConnectExtension && onConnectExtension() }, busy ? 'Connecting…' : 'Connect extension'),
743
- ];
744
- };
745
- return h('div', {
746
- class: 'ov-auth-backdrop', role: 'presentation',
747
- ref: (el) => {
748
- if (!el || el._ovAuth) return; el._ovAuth = true;
749
- el.addEventListener('mousedown', (e) => {
750
- const panel = el.querySelector('.ov-auth-panel');
751
- if (panel && !panel.contains(e.target)) close();
752
- });
753
- },
754
- },
755
- h('div', {
756
- class: 'ov-auth-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Sign in',
757
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } },
758
- },
759
- h('div', { class: 'ov-auth-head' },
760
- h('h2', { class: 'ov-auth-title' }, 'Sign in'),
761
- h('button', { type: 'button', class: 'ov-auth-x', 'aria-label': 'close', onclick: close }, Icon('x'))
762
- ),
763
- h('div', { class: 'ov-auth-tabs', role: 'tablist',
764
- onkeydown: (e) => {
765
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
766
- const panel = e.currentTarget.closest('.ov-auth-panel');
767
- const tabs = panel ? [...panel.querySelectorAll('.ov-auth-tab')] : [];
768
- if (!tabs.length) return;
769
- const idx = tabs.indexOf(document.activeElement);
770
- if (idx < 0) return;
771
- e.preventDefault();
772
- const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
773
- tabs[next].focus();
774
- onModeChange && onModeChange(modes[next].id);
775
- },
776
- },
777
- ...modes.map(m => h('button', {
778
- type: 'button', role: 'tab', key: 'am-' + m.id,
779
- id: 'ov-auth-tab-' + m.id,
780
- class: 'ov-auth-tab' + (m.id === mode ? ' is-active' : ''),
781
- 'aria-selected': m.id === mode ? 'true' : 'false',
782
- 'aria-controls': 'ov-auth-panel',
783
- onclick: () => onModeChange && onModeChange(m.id),
784
- }, m.label))
785
- ),
786
- h('div', { class: 'ov-auth-body', id: 'ov-auth-panel', role: 'tabpanel',
787
- 'aria-labelledby': 'ov-auth-tab-' + mode }, ...body()),
788
- error ? h('div', { class: 'ov-auth-error', role: 'alert' }, String(error)) : null
789
- )
790
- );
791
- }
792
-
793
- // MenuButton — icon-trigger select menu: one option carries a checkmark
794
- // (the active selection), roving keyboard nav mirrors Dropdown's own
795
- // open/close/outside-click/typeahead wiring, plus a stale/unavailable
796
- // per-item state that renders as a muted "unavailable — retry" row instead
797
- // of a normal selectable item (ported from docstudio's model-picker menu,
798
- // which shows a retry affordance when its option list fails to load).
799
- // Zero-option and single-option lists degrade gracefully: an empty list
800
- // renders a static "No options available" row (no crash, no keyboard trap,
801
- // nothing focusable); roving nav on a single-option list simply refocuses
802
- // the same item on every Arrow press (wrap-to-self), never throws.
803
- export function MenuButton({ trigger, items = [], selected, onSelect, onRetry, placement = 'bottom-start', ariaLabel = 'Menu', emptyText = 'No options available' } = {}) {
804
- const menu = useRovingMenu({ itemSelector: '[role="menuitemradio"]:not([aria-disabled="true"])', items, typeahead: true, placement });
805
- const select = (it) => { if (it.disabled || it.unavailable) return; if (onSelect) onSelect(it.id, it); menu.close(); };
806
- const buildMenuEl = () => {
807
- const el = document.createElement('div');
808
- el.className = 'ds-popover ov-menubutton-menu';
809
- el.setAttribute('role', 'menu');
810
- el.setAttribute('aria-label', ariaLabel);
811
- const tree = items.length
812
- ? h('div', { class: 'ov-menubutton-list' },
813
- ...items.map((it, i) => it.unavailable
814
- ? h('div', { key: it.id || i, class: 'ov-menubutton-item is-unavailable' },
815
- h('span', { class: 'ov-menubutton-label' }, it.label || 'Unavailable'),
816
- h('button', { type: 'button', class: 'ov-menubutton-retry', onclick: () => onRetry && onRetry(it.id, it) }, 'Retry')
817
- )
818
- : h('button', {
819
- key: it.id || i, type: 'button', role: 'menuitemradio',
820
- 'aria-checked': it.id === selected ? 'true' : 'false',
821
- class: 'ov-menubutton-item' + (it.disabled ? '' : ''),
822
- 'aria-disabled': it.disabled ? 'true' : 'false',
823
- tabindex: '-1', onclick: () => select(it),
824
- },
825
- h('span', { class: 'ov-menubutton-check', 'aria-hidden': 'true' }, it.id === selected ? Icon('check', { size: 14 }) : ''),
826
- h('span', { class: 'ov-menubutton-label' }, it.label)
827
- )))
828
- : h('div', { class: 'ov-menubutton-empty' }, emptyText);
829
- webjsx.applyDiff(el, tree);
830
- return el;
831
- };
832
- const onTrigClick = () => menu.onTrigClick(buildMenuEl);
833
- const onTrigKey = (e) => menu.onTrigKey(e, buildMenuEl);
834
- const refFn = menu.refFn('_ovMenuButton');
835
- const child = (typeof trigger === 'function') ? trigger() : trigger;
836
- const wireRef = (el) => { refFn(el); if (el) { el.addEventListener('click', onTrigClick); el.addEventListener('keydown', onTrigKey); } };
837
- return (child && child.type)
838
- ? webjsx.createElement(child.type, { ...(child.props || {}), ref: wireRef }, ...(child.children || []))
839
- : h('button', { type: 'button', class: 'ov-menubutton-trigger', ref: wireRef }, child || 'Select');
840
- }
841
-
842
- // VideoLightbox — fullscreen video player overlay with backdrop dismiss.
843
- export function VideoLightbox({ src, label = '', open = false, onClose } = {}) {
844
- if (!open || !src) return null;
845
- const close = () => onClose && onClose();
846
- return h('div', {
847
- class: 'ov-lightbox-backdrop', role: 'dialog', 'aria-modal': 'true', 'aria-label': label || 'Video',
848
- tabindex: '-1',
849
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } },
850
- ref: (el) => { if (el && !el._ovLb) { el._ovLb = true; setTimeout(() => el.focus(), 0); } },
851
- onmousedown: (e) => { if (e.target === e.currentTarget) close(); },
852
- },
853
- h('button', { type: 'button', class: 'ov-lightbox-x', 'aria-label': 'close', onclick: close }, Icon('x')),
854
- h('div', { class: 'ov-lightbox-stage' },
855
- h('video', { class: 'ov-lightbox-video', src, controls: true, autoplay: true, playsinline: true }),
856
- label ? h('div', { class: 'ov-lightbox-label' }, label) : null
857
- )
858
- );
859
- }
5
+ //
6
+ // This module is a barrel: every component lives in a single-responsibility
7
+ // submodule under ./overlay-primitives/, and the public export surface here is
8
+ // unchanged no consumer import needs to move. `trapTab` and `useRovingMenu`
9
+ // are re-exported too: they are consumed cross-module (shell.js imports
10
+ // trapTab from this path) even though the components.js barrel does not
11
+ // forward them.
12
+
13
+ import { useFloating, useLongPress, withBusy, trapTab } from './overlay-primitives/floating.js';
14
+ import { Tooltip } from './overlay-primitives/tooltip.js';
15
+ import { Popover } from './overlay-primitives/popover.js';
16
+ import { useRovingMenu } from './overlay-primitives/roving-menu.js';
17
+ import { Dropdown, PermissionMenu, MenuButton } from './overlay-primitives/menus.js';
18
+ import { ApprovalPrompt } from './overlay-primitives/approval-prompt.js';
19
+ import { CommandPalette } from './overlay-primitives/command-palette.js';
20
+ import { EmojiPicker } from './overlay-primitives/emoji-picker.js';
21
+ import { SettingsPopover } from './overlay-primitives/settings-popover.js';
22
+ import { AuthModal } from './overlay-primitives/auth-modal.js';
23
+ import { BootOverlay, VideoLightbox } from './overlay-primitives/full-screen.js';
24
+
25
+ export {
26
+ useFloating, useLongPress, withBusy, trapTab,
27
+ Tooltip,
28
+ Popover,
29
+ useRovingMenu,
30
+ Dropdown, PermissionMenu, MenuButton,
31
+ ApprovalPrompt,
32
+ CommandPalette,
33
+ EmojiPicker,
34
+ SettingsPopover,
35
+ AuthModal,
36
+ BootOverlay, VideoLightbox,
37
+ };