anentrypoint-design 0.0.172 → 0.0.174

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.
@@ -38,6 +38,17 @@ const refreshError = (err) => err ? h('div', { class: 'ds-alert ds-alert-warn',
38
38
  const liveRegion = (msg) => h('div', { class: 'fd-sr-live', role: 'status', 'aria-live': 'polite' }, msg || '');
39
39
  // Truncate with a title tooltip carrying the full text.
40
40
  const trunc = (s, n = 90) => { const str = String(s || ''); return str.length > n ? { text: str.slice(0, n) + '…', title: str } : { text: str, title: null }; };
41
+ // Named truncation widths so list pages cap display consistently (and any
42
+ // raw .slice(0,N) on user text routes through trunc() for ellipsis + tooltip).
43
+ const TRUNC_TITLE = 60; // session/skill/tool titles
44
+ const TRUNC_SUB = 80; // row sub-text (prompts, descriptions)
45
+ const TRUNC_OUTPUT = 70; // batch output cells
46
+ const TRUNC_DESC = 90; // long descriptions
47
+ const TRUNC_PROMPT = 50; // batch prompt cells
48
+ // Render trunc() result as a span carrying the full text in its title tooltip.
49
+ const truncSpan = (s, n) => { const t = trunc(s, n); return h('span', { title: t.title }, t.text); };
50
+ // Cap a raw JSON dump for an inline table cell without losing the data via tooltip.
51
+ const truncJson = (v, n = TRUNC_TITLE) => truncSpan(JSON.stringify(v), n);
41
52
  // Autoscroll a thread only when the user is already near the bottom, so
42
53
  // scrolling up to read history is not yanked back down on the next render.
43
54
  const stickyScroll = (el) => { if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; if (nearBottom) el.scrollTop = el.scrollHeight; };
@@ -47,13 +58,22 @@ const stickyScroll = (el) => { if (!el) return; const nearBottom = el.scrollHeig
47
58
  export const home = makePage((ctx) => {
48
59
  async function load() {
49
60
  try {
50
- const [health, agents, sessions] = await Promise.all([
61
+ // tools/skills counts come from the host when injected; otherwise
62
+ // fall back to the same /api/* endpoints the tools/skills pages use
63
+ // so the home KPIs never render an em-dash placeholder.
64
+ const needTools = ctx.host?.pi?.tools?.size == null;
65
+ const needSkills = ctx.host?.pi?.skills?.size == null;
66
+ const [health, agents, sessions, toolsList, skillsList] = await Promise.all([
51
67
  api('/api/health').catch(() => null),
52
68
  api('/api/agents').catch(() => null),
53
69
  api('/api/sessions').catch((e) => ({ _err: e })),
70
+ needTools ? api('/api/tools').catch(() => null) : Promise.resolve(null),
71
+ needSkills ? api('/api/skills').catch(() => null) : Promise.resolve(null),
54
72
  ]);
73
+ const toolsCount = needTools ? (Array.isArray(toolsList) ? toolsList.length : (toolsList?.tools?.length ?? null)) : null;
74
+ const skillsCount = needSkills ? (Array.isArray(skillsList) ? skillsList.length : (skillsList?.skills?.length ?? null)) : null;
55
75
  const sessFailed = sessions && sessions._err;
56
- ctx.set({ loading: false, health, agents, sessions: Array.isArray(sessions) ? sessions : [], sessFailed, error: null });
76
+ ctx.set({ loading: false, health, agents, sessions: Array.isArray(sessions) ? sessions : [], sessFailed, toolsCount, skillsCount, error: null });
57
77
  } catch (e) { ctx.set({ loading: false, error: e }); }
58
78
  }
59
79
  load();
@@ -64,8 +84,8 @@ export const home = makePage((ctx) => {
64
84
  if (s.error) return errorState(s.error, load);
65
85
  const sessions = s.sessions || [];
66
86
  const agents = s.agents || {};
67
- const tools = ctx.host?.pi?.tools?.size ?? '—';
68
- const skills = ctx.host?.pi?.skills?.size ?? '—';
87
+ const tools = ctx.host?.pi?.tools?.size ?? s.toolsCount ?? '—';
88
+ const skills = ctx.host?.pi?.skills?.size ?? s.skillsCount ?? '—';
69
89
  return [
70
90
  PageHeader({ eyebrow: 'freddie', title: 'dashboard', lede: 'agent harness · live overview' }),
71
91
  Kpi({ items: [
@@ -80,11 +100,11 @@ export const home = makePage((ctx) => {
80
100
  : sessions.length
81
101
  ? Table({
82
102
  headers: ['session', 'platform', 'updated'],
83
- rows: sessions.slice(0, 8).map(x => { const t = trunc(x.title || x.id, 60); return [h('span', { title: t.title }, t.text), x.platform || '—', fmtAgo(x.updated_at)]; }),
103
+ rows: sessions.slice(0, 8).map(x => [truncSpan(x.title || x.id, TRUNC_TITLE), x.platform || '—', fmtAgo(x.updated_at)]),
84
104
  })
85
105
  : emptyState('no sessions yet')),
86
106
  section('health',
87
- s.health ? Table({ headers: ['check', 'status'], rows: Object.entries(s.health).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]) })
107
+ s.health ? Table({ headers: ['check', 'status'], rows: Object.entries(s.health).map(([k, v]) => [k, typeof v === 'object' ? truncJson(v) : String(v)]) })
88
108
  : emptyState('health endpoint unavailable')),
89
109
  ];
90
110
  };
@@ -140,7 +160,7 @@ export const voice = makePage((ctx) => {
140
160
  load();
141
161
  return () => {
142
162
  const s = ctx.state;
143
- if (s.loading) return loadingState();
163
+ if (s.loading) return loadingState('loading voice config…');
144
164
  const v = s.voice;
145
165
  const enabled = v && (v.enabled || v.transcription || v.tts);
146
166
  return [
@@ -174,7 +194,7 @@ export const sessions = makePage((ctx) => {
174
194
  load();
175
195
  return () => {
176
196
  const s = ctx.state;
177
- if (s.loading) return loadingState();
197
+ if (s.loading) return loadingState('loading sessions…');
178
198
  if (s.error && !s.list) return errorState(s.error, load);
179
199
  const list = Array.isArray(s.list) ? s.list : [];
180
200
  return [
@@ -185,10 +205,10 @@ export const sessions = makePage((ctx) => {
185
205
  list.length
186
206
  ? Table({ headers: ['session', 'platform', 'updated'], onRowClick: (i) => open(list[i].id),
187
207
  rowLabels: list.map(x => x.title || x.id),
188
- rows: list.map(x => { const t = trunc(x.title || x.id, 60); return [h('span', { title: t.title }, t.text), x.platform || '—', fmtAgo(x.updated_at)]; }) })
208
+ rows: list.map(x => [truncSpan(x.title || x.id, TRUNC_TITLE), x.platform || '—', fmtAgo(x.updated_at)]) })
189
209
  : emptyState('no sessions match')),
190
210
  s.selected ? section('messages · ' + s.selected,
191
- s.msgLoading ? loadingState()
211
+ s.msgLoading ? loadingState('loading messages…')
192
212
  : (s.messages || []).length ? (s.messages).map((m, i) => ChatMessage({ role: m.role, text: m.content || m.text || '', time: m.ts ? fmtTime(m.ts) : '', key: i }))
193
213
  : emptyState('no messages')) : null,
194
214
  ];
@@ -216,7 +236,7 @@ export const projects = makePage((ctx) => {
216
236
  load();
217
237
  return () => {
218
238
  const s = ctx.state;
219
- if (s.loading) return loadingState();
239
+ if (s.loading) return loadingState('loading projects…');
220
240
  if (s.error && !s.data) return errorState(s.error, load);
221
241
  const d = s.data || {}; const list = d.projects || [];
222
242
  const activeName = (d.active && d.active.name) || d.active || 'default';
@@ -246,11 +266,12 @@ export const agents = makePage((ctx) => {
246
266
  load(); ctx.interval(load, 5000);
247
267
  return () => {
248
268
  const s = ctx.state;
249
- if (s.loading) return loadingState();
250
- if (s.error) return errorState(s.error, load);
269
+ if (s.loading) return loadingState('loading agents…');
270
+ if (s.error && !s.data) return errorState(s.error, load);
251
271
  const d = s.data || {};
252
272
  return [
253
273
  PageHeader({ eyebrow: 'freddie', title: 'agents', lede: 'live agent activity' }),
274
+ s.error && s.data ? refreshError(s.error) : null,
254
275
  Kpi({ items: [[d.count ?? 0, 'active'], [d.turns ?? 0, 'total turns'], [d.last_activity ? fmtAgo(d.last_activity) : '—', 'last activity']] }),
255
276
  section('detail', Table({ headers: ['field', 'value'], rows: Object.entries(d).map(([k, v]) => [k, String(v)]) })),
256
277
  ];
@@ -272,13 +293,14 @@ export const analytics = makePage((ctx) => {
272
293
  load(); ctx.interval(load, 15000);
273
294
  return () => {
274
295
  const s = ctx.state;
275
- if (s.loading) return loadingState();
276
- if (s.error) return errorState(s.error, load);
296
+ if (s.loading) return loadingState('loading analytics…');
297
+ if (s.error && !s.sampler && !s.avail) return errorState(s.error, load);
277
298
  const samp = s.sampler?.status ? Object.values(s.sampler.status) : [];
278
299
  const ok = samp.filter(x => x && x.available !== false).length;
279
300
  const sum = s.avail?.summary || {};
280
301
  return [
281
302
  PageHeader({ eyebrow: 'freddie', title: 'analytics', lede: 'provider availability & sampler health' }),
303
+ s.error && (s.sampler || s.avail) ? refreshError(s.error) : null,
282
304
  Kpi({ items: [[ok + '/' + samp.length, 'providers up'], [sum.total_models ?? '—', 'models'], [sum.usable_in_any_mode ?? '—', 'usable']] }),
283
305
  section('sampler', samp.length ? Table({ headers: ['provider', 'available', 'fails'], rows: Object.entries(s.sampler.status).map(([k, v]) => [k, v.available === false ? 'no' : 'yes', String(v.failCount ?? 0)]) }) : emptyState('no sampler data')),
284
306
  ];
@@ -309,7 +331,7 @@ export const models = makePage((ctx) => {
309
331
  load();
310
332
  return () => {
311
333
  const s = ctx.state;
312
- if (s.loading) return loadingState();
334
+ if (s.loading) return loadingState('loading models…');
313
335
  if (s.error && !s.providers) return errorState(s.error, load);
314
336
  const providers = Array.isArray(s.providers) ? s.providers : [];
315
337
  const cached = s.cached || {};
@@ -347,14 +369,14 @@ export const cron = makePage((ctx) => {
347
369
  load();
348
370
  return () => {
349
371
  const s = ctx.state;
350
- if (s.loading) return loadingState();
372
+ if (s.loading) return loadingState('loading cron jobs…');
351
373
  if (s.error && !s.list) return errorState(s.error, load);
352
374
  const list = Array.isArray(s.list) ? s.list : [];
353
375
  return [
354
376
  PageHeader({ eyebrow: 'freddie', title: 'cron', lede: list.length + ' scheduled jobs' }),
355
377
  noteAlert(s.note),
356
378
  section('jobs', list.length ? list.map((j, i) => Row({
357
- key: i, code: j.enabled ? Icon('play') : Icon('pause'), title: j.cron, sub: (j.prompt || '').slice(0, 80),
379
+ key: i, code: j.enabled ? Icon('play') : Icon('pause'), title: j.cron, sub: trunc(j.prompt, TRUNC_SUB).text,
358
380
  trailing: Btn({ danger: true, children: 'delete', onClick: () => del(j.id) }),
359
381
  })) : emptyState('no cron jobs')),
360
382
  section('new job',
@@ -373,13 +395,13 @@ export const skills = makePage((ctx) => {
373
395
  load();
374
396
  return () => {
375
397
  const s = ctx.state;
376
- if (s.loading) return loadingState();
398
+ if (s.loading) return loadingState('loading skills…');
377
399
  if (s.error) return errorState(s.error, load);
378
400
  const list = Array.isArray(s.list) ? s.list : (s.list?.skills || []);
379
401
  return [
380
402
  PageHeader({ eyebrow: 'freddie', title: 'skills', lede: list.length + ' skills' }),
381
403
  section('skills', list.length ? list.map((sk, i) => h('div', { key: i },
382
- Row({ code: (sk.source || 'fs').slice(0, 3), title: sk.name, sub: (sk.description || '').slice(0, 90),
404
+ Row({ code: (sk.source || 'fs').slice(0, 3), title: sk.name, sub: trunc(sk.description, TRUNC_DESC).text,
383
405
  onClick: () => ctx.set({ open: s.open === i ? null : i }), active: s.open === i }),
384
406
  s.open === i ? h('pre', { class: 'fd-pre fd-skill-body' }, sk.body || sk.content || '(no body)') : null,
385
407
  )) : emptyState('no skills')),
@@ -412,7 +434,7 @@ export const config = makePage((ctx) => {
412
434
  load();
413
435
  return () => {
414
436
  const s = ctx.state;
415
- if (s.loading) return loadingState();
437
+ if (s.loading) return loadingState('loading config…');
416
438
  if (s.error) return errorState(s.error, load);
417
439
  const cfg = s.cfg || {};
418
440
  const flat = Object.entries(cfg).filter(([, v]) => typeof v !== 'object' || v === null);
@@ -433,7 +455,8 @@ export const config = makePage((ctx) => {
433
455
  TextField({ key: i, label: k, value: String(ctx.state.edited[k] ?? v ?? ''), onInput: (val) => { ctx.state.edited[k] = val; } })
434
456
  ) : emptyState('no scalar config keys')),
435
457
  section('raw', h('pre', { class: 'fd-pre' }, JSON.stringify(cfg, null, 2))),
436
- Btn({ primary: true, disabled: s.busy || !Object.keys(s.edited).length, children: s.busy ? 'saving…' : 'save changes', onClick: save }),
458
+ section('actions',
459
+ Btn({ primary: true, disabled: s.busy || !Object.keys(s.edited).length, children: s.busy ? 'saving…' : 'save changes', onClick: save })),
437
460
  ];
438
461
  };
439
462
  });
@@ -445,10 +468,10 @@ export const env = makePage((ctx) => {
445
468
  load();
446
469
  return () => {
447
470
  const s = ctx.state;
448
- if (s.loading) return loadingState();
471
+ if (s.loading) return loadingState('loading environment…');
449
472
  if (s.error) return errorState(s.error, load);
450
473
  const d = s.data || {};
451
- const rows = Object.entries(d).map(([k, v]) => [k, v === true || v === 'set' ? Chip({ tone: 'ok', children: 'set' }) : (v ? String(v) : Chip({ tone: 'neutral', children: 'unset' }))]);
474
+ const rows = Object.entries(d).map(([k, v]) => [k, v === true || v === 'set' ? Chip({ tone: 'ok', children: 'set' }) : (v ? truncSpan(v, TRUNC_SUB) : Chip({ tone: 'neutral', children: 'unset' }))]);
452
475
  return [
453
476
  PageHeader({ eyebrow: 'freddie', title: 'env', lede: 'environment / key presence' }),
454
477
  section('variables', rows.length ? Table({ headers: ['key', 'status'], rows }) : emptyState('no env data')),
@@ -464,7 +487,7 @@ export const tools = makePage((ctx) => {
464
487
  load();
465
488
  return () => {
466
489
  const s = ctx.state;
467
- if (s.loading) return loadingState();
490
+ if (s.loading) return loadingState('loading tools…');
468
491
  if (s.error) return errorState(s.error, load);
469
492
  let list = Array.isArray(s.list) ? s.list : (s.list?.tools || []);
470
493
  if (s.q) list = list.filter(t => (t.name || '').includes(s.q));
@@ -472,9 +495,9 @@ export const tools = makePage((ctx) => {
472
495
  for (const t of list) { const g = t.toolset || 'core'; (groups[g] = groups[g] || []).push(t); }
473
496
  return [
474
497
  PageHeader({ eyebrow: 'freddie', title: 'tools', lede: list.length + ' tools' }),
475
- SearchInput({ value: s.q, placeholder: 'filter tools…', onInput: (v) => ctx.set({ q: v }) }),
498
+ SearchInput({ value: s.q, label: 'filter tools', placeholder: 'filter tools…', onInput: (v) => ctx.set({ q: v }) }),
476
499
  ...Object.entries(groups).map(([g, ts]) => section(g + ' · ' + ts.length, ts.map((t, i) => h('div', { key: i },
477
- Row({ title: t.name, sub: (t.schema?.description || t.description || '').slice(0, 90), onClick: () => ctx.set({ open: ctx.state.open === t.name ? null : t.name }), active: ctx.state.open === t.name }),
500
+ Row({ title: t.name, sub: trunc(t.schema?.description || t.description, TRUNC_DESC).text, onClick: () => ctx.set({ open: ctx.state.open === t.name ? null : t.name }), active: ctx.state.open === t.name }),
478
501
  ctx.state.open === t.name ? h('pre', { class: 'fd-pre' }, JSON.stringify(t.schema || t, null, 2)) : null,
479
502
  )))),
480
503
  list.length ? null : emptyState('no tools match'),
@@ -501,7 +524,7 @@ export const batch = makePage((ctx) => {
501
524
  noteAlert(s.note),
502
525
  section('prompts',
503
526
  TextField({ label: 'prompts (one per line)', value: s.prompts, multiline: true, rows: 6, onInput: (v) => { s.prompts = v; } }),
504
- TextField({ label: 'concurrency', type: 'number', value: String(s.concurrency), onInput: (v) => { s.concurrency = v; } }),
527
+ TextField({ label: 'concurrency', type: 'number', min: 1, 'aria-label': 'batch concurrency', value: String(s.concurrency), onInput: (v) => { s.concurrency = v; } }),
505
528
  Btn({ primary: true, disabled: s.busy, children: s.busy ? 'running…' : 'run batch', onClick: run })),
506
529
  s.result ? section('result', (() => {
507
530
  const r = s.result;
@@ -510,9 +533,7 @@ export const batch = makePage((ctx) => {
510
533
  return [
511
534
  Kpi({ items: [[items.length, 'prompts'], [items.filter(x => !x.error).length, 'ok'], [items.filter(x => x.error).length, 'errors']] }),
512
535
  Table({ headers: ['#', 'prompt', 'status', 'output'], rows: items.map((x, i) => {
513
- const p = trunc(x.prompt || x.input || '', 50);
514
- const out = trunc(x.error || x.result || x.content || x.output || '', 70);
515
- return [String(i + 1), h('span', { title: p.title }, p.text), x.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'ok' }), h('span', { title: out.title }, out.text)];
536
+ return [String(i + 1), truncSpan(x.prompt || x.input || '', TRUNC_PROMPT), x.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'ok' }), truncSpan(x.error || x.result || x.content || x.output || '', TRUNC_OUTPUT)];
516
537
  }) }),
517
538
  ];
518
539
  })()) : null,
@@ -527,13 +548,14 @@ export const gateway = makePage((ctx) => {
527
548
  load(); ctx.interval(load, 10000);
528
549
  return () => {
529
550
  const s = ctx.state;
530
- if (s.loading) return loadingState();
531
- if (s.error) return errorState(s.error, load);
551
+ if (s.loading) return loadingState('loading gateway…');
552
+ if (s.error && !s.data) return errorState(s.error, load);
532
553
  const d = s.data || {};
533
554
  const platforms = d.platforms || d;
534
555
  const rows = Object.entries(platforms).map(([k, v]) => [k, typeof v === 'object' ? (v.running || v.up ? Chip({ tone: 'ok', children: 'up' }) : Chip({ tone: 'miss', children: 'down' })) : String(v)]);
535
556
  return [
536
557
  PageHeader({ eyebrow: 'freddie', title: 'gateway', lede: 'messaging platform status' }),
558
+ s.error && s.data ? refreshError(s.error) : null,
537
559
  section('platforms', rows.length ? Table({ headers: ['platform', 'status'], rows }) : emptyState('no platforms configured')),
538
560
  ];
539
561
  };
@@ -566,7 +588,7 @@ export const chains = makePage((ctx) => {
566
588
  load();
567
589
  return () => {
568
590
  const s = ctx.state;
569
- if (s.loading) return loadingState();
591
+ if (s.loading) return loadingState('loading chains…');
570
592
  if (s.error && !s.cfg && !s.health) return errorState(s.error, load);
571
593
  const chainsList = s.list?.chains || s.list || [];
572
594
  const up = s.health && (s.health.ok || s.health.status === 'ok' || s.health.healthy);
@@ -593,15 +615,16 @@ export const machines = makePage((ctx) => {
593
615
  load(); ctx.interval(load, 8000);
594
616
  return () => {
595
617
  const s = ctx.state;
596
- if (s.loading) return loadingState();
597
- if (s.error) return errorState(s.error, load);
618
+ if (s.loading) return loadingState('loading machines…');
619
+ if (s.error && !s.data) return errorState(s.error, load);
598
620
  const d = s.data || {};
599
621
  const list = Array.isArray(d) ? d : (d.machines || Object.entries(d).map(([kind, v]) => ({ kind, ...(typeof v === 'object' ? v : { value: v }) })));
600
622
  return [
601
623
  PageHeader({ eyebrow: 'freddie', title: 'machines', lede: 'persisted xstate machine census' }),
624
+ s.error && s.data ? refreshError(s.error) : null,
602
625
  section('machines', list.length ? Table({
603
626
  headers: ['kind', 'key', 'state'],
604
- rows: list.map(m => [m.kind || '—', m.key || m.machine_id || '—', m.state || m.value || JSON.stringify(m).slice(0, 60)]),
627
+ rows: list.map(m => [m.kind || '—', m.key || m.machine_id || '—', m.state || m.value || truncJson(m)]),
605
628
  }) : emptyState('no live machines')),
606
629
  ];
607
630
  };
@@ -622,13 +645,14 @@ export const health = makePage((ctx) => {
622
645
  load(); ctx.interval(load, 15000);
623
646
  return () => {
624
647
  const s = ctx.state;
625
- if (s.loading) return loadingState();
626
- if (s.error) return errorState(s.error, load);
648
+ if (s.loading) return loadingState('loading health…');
649
+ if (s.error && !s.health && !s.providers) return errorState(s.error, load);
627
650
  const hd = s.health || {};
628
651
  const provs = Array.isArray(s.providers) ? s.providers : (s.providers?.providers || []);
629
652
  return [
630
653
  PageHeader({ eyebrow: 'freddie', title: 'health', lede: 'system & provider health', right: hd.ok ? Chip({ tone: 'ok', children: 'healthy' }) : Chip({ tone: 'miss', children: 'degraded' }) }),
631
- section('checks', Object.keys(hd).length ? Table({ headers: ['check', 'status'], rows: Object.entries(hd).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : (v === true ? Chip({ tone: 'ok', children: 'ok' }) : v === false ? Chip({ tone: 'miss', children: 'no' }) : String(v))]) }) : emptyState('no health data')),
654
+ s.error && (s.health || s.providers) ? refreshError(s.error) : null,
655
+ section('checks', Object.keys(hd).length ? Table({ headers: ['check', 'status'], rows: Object.entries(hd).map(([k, v]) => [k, typeof v === 'object' ? truncJson(v) : (v === true ? Chip({ tone: 'ok', children: 'ok' }) : v === false ? Chip({ tone: 'miss', children: 'no' }) : String(v))]) }) : emptyState('no health data')),
632
656
  provs.length ? section('providers', Table({ headers: ['provider', 'status'], rows: provs.map(p => { const n = typeof p === 'string' ? p : p.name || p.id; const ok = typeof p === 'object' ? (p.ok ?? p.available) : null; return [n, ok == null ? '—' : (ok ? Chip({ tone: 'ok', children: 'up' }) : Chip({ tone: 'miss', children: 'down' }))]; }) })) : null,
633
657
  ];
634
658
  };
@@ -647,7 +671,7 @@ export const debug = makePage((ctx) => {
647
671
  load();
648
672
  return () => {
649
673
  const s = ctx.state;
650
- if (s.loading) return loadingState();
674
+ if (s.loading) return loadingState('loading debug snapshots…');
651
675
  if (s.error) return errorState(s.error, load);
652
676
  const d = s.data || {};
653
677
  const subsystems = d.subsystems || Object.keys(d);
@@ -1,6 +1,7 @@
1
1
  // Interaction primitives — pointer drag/drop + keyboard shortcuts.
2
2
  // Pointer Events only (touch+mouse). Visuals via editor-primitives.css.
3
3
  import * as webjsx from '../../vendor/webjsx/index.js';
4
+ import { Icon } from './shell.js';
4
5
  const h = webjsx.createElement;
5
6
  const DRAG_THRESHOLD = 5;
6
7
  const IS_MAC = (typeof navigator !== 'undefined') && /Mac|iPhone|iPad/.test(navigator.platform || '');
@@ -146,7 +147,7 @@ export function Reorderable({ items = [], getKey, renderItem, onReorder, axis =
146
147
  h('button', {
147
148
  type: 'button', class: 'ds-reorder-handle',
148
149
  'aria-label': 'Reorder', tabindex: '0',
149
- }, '⋮⋮'),
150
+ }, Icon('more-horizontal')),
150
151
  renderItem ? renderItem(item, i) : null
151
152
  );
152
153
  })
@@ -203,8 +204,30 @@ export function ShortcutHelpDialog({ open = false, onClose, registry } = {}) {
203
204
  const list = registry || Array.from(SHORTCUT_REGISTRY);
204
205
  const groups = {};
205
206
  list.forEach(r => { (groups[r.scope] = groups[r.scope] || []).push(r); });
207
+ // Escape-to-close, Tab focus trap, and autofocus on open — wired through a
208
+ // ref so teardown runs on the webjsx ref(null) unmount branch.
209
+ const dialogRef = (el) => {
210
+ if (!el) { if (ShortcutHelpDialog._teardown) { ShortcutHelpDialog._teardown(); ShortcutHelpDialog._teardown = null; } return; }
211
+ const focusables = () => el.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
212
+ const onKey = (e) => {
213
+ if (e.key === 'Escape') { e.preventDefault(); if (onClose) onClose(); return; }
214
+ if (e.key === 'Tab') {
215
+ const f = focusables();
216
+ if (!f.length) { e.preventDefault(); return; }
217
+ const first = f[0], last = f[f.length - 1];
218
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
219
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
220
+ }
221
+ };
222
+ el.addEventListener('keydown', onKey);
223
+ ShortcutHelpDialog._teardown = () => el.removeEventListener('keydown', onKey);
224
+ // The dialog itself is focusable (tabindex=-1) so it always has a home
225
+ // for focus even when it contains no interactive controls.
226
+ const f = focusables();
227
+ (f[0] || el).focus();
228
+ };
206
229
  return h('div', { class: 'ds-ep-dialog-backdrop', onmousedown: (e) => { if (e.target === e.currentTarget && onClose) onClose(); } },
207
- h('div', { class: 'ds-ep-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts' },
230
+ h('div', { class: 'ds-ep-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts', tabindex: '-1', ref: dialogRef },
208
231
  h('h2', null, 'Keyboard shortcuts'),
209
232
  ...Object.entries(groups).map(([scope, rows]) =>
210
233
  h('section', { class: 'ds-kbd-group' },
@@ -9,6 +9,16 @@ const h = webjsx.createElement;
9
9
  const kids = (c) => c == null ? [] : (Array.isArray(c) ? c : [c]);
10
10
  const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
11
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
+
12
22
  // useFloating — compute left/top + auto-flip; re-runs on resize/scroll.
13
23
  export function useFloating(anchorEl, contentEl, { placement = 'bottom-start', offset = 8 } = {}) {
14
24
  if (!anchorEl || !contentEl) return { update() {}, dispose() {}, finalPlacement: placement };
@@ -30,8 +40,8 @@ export function useFloating(anchorEl, contentEl, { placement = 'bottom-start', o
30
40
  x = s === 'right' ? a.right + offset : a.left - offset - c.width;
31
41
  y = align === 'start' ? a.top : align === 'end' ? a.bottom - c.height : a.top + (a.height - c.height) / 2;
32
42
  }
33
- x = Math.max(4, Math.min(vw - c.width - 4, x));
34
- y = Math.max(4, Math.min(vh - c.height - 4, y));
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));
35
45
  contentEl.style.left = x + 'px';
36
46
  contentEl.style.top = y + 'px';
37
47
  finalPlacement = s + '-' + align;
@@ -86,7 +96,7 @@ function _showTip(trigger, label, placement, kind) {
86
96
  _tipEl.id = 'ds-tip-' + (++_tipId);
87
97
  trigger.setAttribute('aria-describedby', _tipEl.id);
88
98
  if (_tipFloat) _tipFloat.dispose();
89
- _tipFloat = useFloating(trigger, _tipEl, { placement, offset: 6 });
99
+ _tipFloat = useFloating(trigger, _tipEl, { placement, offset: FLOAT_OFFSET_TOOLTIP });
90
100
  }
91
101
 
92
102
  export function Tooltip({ children, label, placement = 'top', delay = 350, kind = 'default' } = {}) {
@@ -126,7 +136,7 @@ export function Popover({ open, anchorEl, onClose, placement = 'bottom-start', c
126
136
  el.tabIndex = -1;
127
137
  document.body.appendChild(el);
128
138
  webjsx.applyDiff(el, h('div', { class: 'ds-popover-inner' }, ...kids(children)));
129
- const floating = useFloating(anchorEl, el, { placement, offset: 6 });
139
+ const floating = useFloating(anchorEl, el, { placement, offset: FLOAT_OFFSET_POPOVER });
130
140
  const close = () => onClose && onClose();
131
141
  const onDown = (e) => { if (el.contains(e.target) || anchorEl.contains(e.target)) return; close(); };
132
142
  const onKey = (e) => {
@@ -203,7 +213,7 @@ export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-st
203
213
  webjsx.applyDiff(menuEl, tree);
204
214
  document.body.appendChild(menuEl);
205
215
  menuEl.addEventListener('keydown', onMenuKey);
206
- floating = useFloating(triggerEl, menuEl, { placement, offset: 4 });
216
+ floating = useFloating(triggerEl, menuEl, { placement, offset: FLOAT_OFFSET_DROPDOWN });
207
217
  document.addEventListener('mousedown', onDown, true);
208
218
  triggerEl.setAttribute('aria-expanded', 'true');
209
219
  if (focusFirst) queueMicrotask(() => focusItem(0));
@@ -225,7 +235,7 @@ export function Dropdown({ trigger, items = [], onSelect, placement = 'bottom-st
225
235
  }
226
236
 
227
237
  // Clamp a fixed-position box to the viewport given desired top-left coords.
228
- function _clampToViewport(x, y, w, h, margin = 8) {
238
+ function _clampToViewport(x, y, w, h, margin = CLAMP_MARGIN) {
229
239
  const vw = (typeof window !== 'undefined' ? window.innerWidth : 1024);
230
240
  const vh = (typeof window !== 'undefined' ? window.innerHeight : 768);
231
241
  return {
@@ -234,6 +244,35 @@ function _clampToViewport(x, y, w, h, margin = 8) {
234
244
  };
235
245
  }
236
246
 
247
+ // Tab focus trap for a dialog root — keeps Tab/Shift+Tab cycling inside `el`.
248
+ // Call from an onkeydown handler; returns true if it handled the event.
249
+ function _trapTab(el, e) {
250
+ if (e.key !== 'Tab') return false;
251
+ const nodes = el.querySelectorAll(FOCUSABLE_SEL);
252
+ if (!nodes.length) { e.preventDefault(); return true; }
253
+ const first = nodes[0], last = nodes[nodes.length - 1], a = document.activeElement;
254
+ if (e.shiftKey && a === first) { e.preventDefault(); last.focus(); return true; }
255
+ if (!e.shiftKey && a === last) { e.preventDefault(); first.focus(); return true; }
256
+ return false;
257
+ }
258
+
259
+ // Shared lifecycle for fixed anchor-positioned popovers (EmojiPicker,
260
+ // SettingsPopover): on mount, place+clamp near (anchorX, anchorY), focus the
261
+ // root, and wire an outside-mousedown close. Returns a cleanup fn the ref(null)
262
+ // branch must call. Both consumers deduped through this so the
263
+ // queueMicrotask/place/clamp/outside-close dance is authored once.
264
+ function _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW, fallbackH, close }) {
265
+ const place = () => {
266
+ const r = el.getBoundingClientRect();
267
+ const { left, top } = _clampToViewport(anchorX, anchorY, r.width || fallbackW, r.height || fallbackH);
268
+ el.style.left = left + 'px'; el.style.top = top + 'px';
269
+ };
270
+ queueMicrotask(() => { place(); el.focus(); });
271
+ const onDown = (e) => { if (!el.contains(e.target)) close(); };
272
+ queueMicrotask(() => document.addEventListener('mousedown', onDown, true));
273
+ return () => document.removeEventListener('mousedown', onDown, true);
274
+ }
275
+
237
276
  // CommandPalette — centered Cmd+K palette with live filter + keyboard nav.
238
277
  export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
239
278
  if (!open) return null;
@@ -275,7 +314,11 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
275
314
  };
276
315
 
277
316
  let rootEl = null, inputEl = null, listEl = null, flat = [];
278
- const close = () => onClose && onClose();
317
+ // Remember the element focused before the palette opened so we can return
318
+ // focus there on close (the input steals focus on mount).
319
+ const prevFocus = (typeof document !== 'undefined') ? document.activeElement : null;
320
+ const restoreFocus = () => { if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus(); };
321
+ const close = () => { restoreFocus(); if (onClose) onClose(); };
279
322
  const choose = (it) => { if (it && onSelect) onSelect(it); };
280
323
 
281
324
  const renderInner = () => {
@@ -320,6 +363,10 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
320
363
  );
321
364
  }
322
365
 
366
+ // Sanctioned literal-emoji exception: an emoji picker's whole purpose is to
367
+ // present emoji, so the glyph ban does not apply to this data table or the
368
+ // per-emoji <button> labels below. This is intentional product content, not
369
+ // decorative chrome.
323
370
  const EMOJI_CATEGORIES = [
324
371
  { id: 'smileys', label: '😀', emoji: ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😅','😉','🙂','😇','🥳','😴','🤩','😜','😢','😭','😡','😱','🥺','😤','😬'] },
325
372
  { id: 'gestures', label: '👍', emoji: ['👍','👎','👌','✌️','🤞','🙏','👏','🙌','💪','👀','🤝','✋','🤙','👋','🤟','☝️'] },
@@ -347,19 +394,11 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
347
394
  return h('div', {
348
395
  class: 'ov-emoji-root', role: 'dialog', 'aria-label': 'Emoji picker',
349
396
  tabindex: '-1',
350
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } },
397
+ onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) _trapTab(rootEl, e); },
351
398
  ref: (el) => {
352
399
  if (!el) { if (rootEl && rootEl._ovEmojiCleanup) rootEl._ovEmojiCleanup(); return; }
353
400
  if (el._ovEmoji) return; el._ovEmoji = true; rootEl = el;
354
- const place = () => {
355
- const r = el.getBoundingClientRect();
356
- const { left, top } = _clampToViewport(anchorX, anchorY, r.width || 260, r.height || 240);
357
- el.style.left = left + 'px'; el.style.top = top + 'px';
358
- };
359
- queueMicrotask(() => { place(); el.focus(); });
360
- const onDown = (e) => { if (!el.contains(e.target)) close(); };
361
- queueMicrotask(() => document.addEventListener('mousedown', onDown, true));
362
- el._ovEmojiCleanup = () => document.removeEventListener('mousedown', onDown, true);
401
+ el._ovEmojiCleanup = _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW: 260, fallbackH: 240, close });
363
402
  },
364
403
  },
365
404
  h('div', { class: 'ov-emoji-tabs', role: 'tablist' },
@@ -401,32 +440,41 @@ export function BootOverlay({ progress = 0, phase = '', errored = false, visible
401
440
  // SettingsPopover — fixed popover with generic section/row control rendering.
402
441
  export function SettingsPopover({ title = 'Settings', open, anchorX = 0, anchorY = 0, sections = [], onClose } = {}) {
403
442
  if (!open) return null;
443
+ let rootEl = null;
404
444
  const close = () => onClose && onClose();
405
445
  const secs = Array.isArray(sections) ? sections : [];
406
446
 
407
447
  const renderRow = (row, i) => {
408
448
  const label = row.label != null ? row.label : (row.title != null ? row.title : '');
409
449
  const kind = row.kind;
410
- const labelNode = h('span', { class: 'ov-set-row-label' }, String(label));
450
+ // Give every interactive control a stable id and point the row label's
451
+ // `for` at it, so the visible label is the control's accessible name.
452
+ const ctrlId = 'ov-set-' + i + '-' + kind;
453
+ const labelNode = h('label', { class: 'ov-set-row-label', for: ctrlId }, String(label));
411
454
  let control = null;
412
455
  if (kind === 'select') {
413
456
  const opts = Array.isArray(row.options) ? row.options : [];
457
+ // Controlled via the `value` prop only — per-option `selected` is
458
+ // dropped so the two don't fight (value wins).
414
459
  control = h('select', {
460
+ id: ctrlId,
415
461
  class: 'ov-set-control', value: row.value != null ? String(row.value) : undefined,
416
462
  onchange: (e) => row.onChange && row.onChange(e.target.value),
417
463
  }, ...opts.map(o => {
418
464
  const v = (o && typeof o === 'object') ? o.value : o;
419
465
  const l = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o;
420
- return h('option', { value: String(v), selected: String(v) === String(row.value) ? 'selected' : undefined }, String(l));
466
+ return h('option', { value: String(v) }, String(l));
421
467
  }));
422
468
  } else if (kind === 'toggle') {
423
469
  control = h('input', {
470
+ id: ctrlId,
424
471
  type: 'checkbox', class: 'ov-set-toggle',
425
472
  checked: row.value ? 'checked' : undefined,
426
473
  onchange: (e) => row.onChange && row.onChange(e.target.checked),
427
474
  });
428
475
  } else if (kind === 'range') {
429
476
  control = h('input', {
477
+ id: ctrlId,
430
478
  type: 'range', class: 'ov-set-control',
431
479
  min: String(row.min != null ? row.min : 0),
432
480
  max: String(row.max != null ? row.max : 100),
@@ -440,23 +488,19 @@ export function SettingsPopover({ title = 'Settings', open, anchorX = 0, anchorY
440
488
  return h('div', { class: 'ov-set-row', key: i }, control);
441
489
  } else {
442
490
  control = h('span', { class: 'ov-set-row-value' }, String(row.value != null ? row.value : ''));
491
+ // Non-interactive value row: a plain span label (no `for` target).
492
+ return h('div', { class: 'ov-set-row', key: i }, h('span', { class: 'ov-set-row-label' }, String(label)), control);
443
493
  }
444
494
  return h('div', { class: 'ov-set-row', key: i }, labelNode, control);
445
495
  };
446
496
 
447
497
  return h('div', {
448
498
  class: 'ov-set-root', role: 'dialog', 'aria-label': String(title), tabindex: '-1',
449
- onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } },
499
+ onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) _trapTab(rootEl, e); },
450
500
  ref: (el) => {
451
- if (!el || el._ovSet) return; el._ovSet = true;
452
- const place = () => {
453
- const r = el.getBoundingClientRect();
454
- const { left, top } = _clampToViewport(anchorX, anchorY, r.width || 280, r.height || 200);
455
- el.style.left = left + 'px'; el.style.top = top + 'px';
456
- };
457
- queueMicrotask(() => { place(); el.focus(); });
458
- const onDown = (e) => { if (!el.contains(e.target)) close(); };
459
- queueMicrotask(() => document.addEventListener('mousedown', onDown, true));
501
+ if (!el) { if (rootEl && rootEl._ovSetCleanup) rootEl._ovSetCleanup(); return; }
502
+ if (el._ovSet) return; el._ovSet = true; rootEl = el;
503
+ el._ovSetCleanup = _anchoredOverlayLifecycle(el, { anchorX, anchorY, fallbackW: 280, fallbackH: 200, close });
460
504
  },
461
505
  },
462
506
  h('div', { class: 'ov-set-head' }, String(title)),
@@ -524,7 +568,7 @@ export function AuthModal({ mode = 'extension', error = '', busy = false, open =
524
568
  },
525
569
  h('div', { class: 'ov-auth-head' },
526
570
  h('h2', { class: 'ov-auth-title' }, 'Sign in'),
527
- h('button', { type: 'button', class: 'ov-auth-x', 'aria-label': 'close', onclick: close }, '×')
571
+ h('button', { type: 'button', class: 'ov-auth-x', 'aria-label': 'close', onclick: close }, Icon('x'))
528
572
  ),
529
573
  h('div', { class: 'ov-auth-tabs', role: 'tablist' },
530
574
  ...modes.map(m => h('button', {
@@ -551,7 +595,7 @@ export function VideoLightbox({ src, label = '', open = false, onClose } = {}) {
551
595
  ref: (el) => { if (el && !el._ovLb) { el._ovLb = true; queueMicrotask(() => el.focus()); } },
552
596
  onmousedown: (e) => { if (e.target === e.currentTarget) close(); },
553
597
  },
554
- h('button', { type: 'button', class: 'ov-lightbox-x', 'aria-label': 'close', onclick: close }, '×'),
598
+ h('button', { type: 'button', class: 'ov-lightbox-x', 'aria-label': 'close', onclick: close }, Icon('x')),
555
599
  h('div', { class: 'ov-lightbox-stage' },
556
600
  h('video', { class: 'ov-lightbox-video', src, controls: true, autoplay: true, playsinline: true }),
557
601
  label ? h('div', { class: 'ov-lightbox-label' }, label) : null