anentrypoint-design 0.0.148 → 0.0.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.148",
3
+ "version": "0.0.151",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -1,6 +1,7 @@
1
1
  // Community surface — matches upstream signatures.
2
2
 
3
3
  import * as webjsx from '../../vendor/webjsx/index.js';
4
+ import { Icon } from './shell.js';
4
5
  const h = webjsx.createElement;
5
6
 
6
7
  export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
@@ -143,9 +144,9 @@ export function UserPanel({ name, tag, color, muted, deafened, onMute, onDeafen,
143
144
  tag ? h('div', { class: 'cm-user-tag' }, tag) : null
144
145
  ),
145
146
  h('div', { class: 'cm-user-controls' },
146
- h('button', { class: 'cm-user-btn' + (muted ? ' muted' : ''), onclick: onMute, 'aria-label': muted ? 'Unmute microphone' : 'Mute microphone', 'aria-pressed': muted ? 'true' : 'false' }, muted ? '🔇' : '🎤'),
147
- h('button', { class: 'cm-user-btn' + (deafened ? ' deafened' : ''), onclick: onDeafen, 'aria-label': deafened ? 'Undeafen' : 'Deafen', 'aria-pressed': deafened ? 'true' : 'false' }, deafened ? '🔕' : '🎧'),
148
- h('button', { class: 'cm-user-btn', onclick: handleSettings, 'aria-label': 'Audio settings', title: 'Open audio settings' }, '')
147
+ h('button', { class: 'cm-user-btn' + (muted ? ' muted' : ''), onclick: onMute, 'aria-label': muted ? 'Unmute microphone' : 'Mute microphone', 'aria-pressed': muted ? 'true' : 'false' }, Icon(muted ? 'mic-off' : 'mic')),
148
+ h('button', { class: 'cm-user-btn' + (deafened ? ' deafened' : ''), onclick: onDeafen, 'aria-label': deafened ? 'Undeafen' : 'Deafen', 'aria-pressed': deafened ? 'true' : 'false' }, Icon(deafened ? 'speaker-off' : 'speaker')),
149
+ h('button', { class: 'cm-user-btn', onclick: handleSettings, 'aria-label': 'Audio settings', title: 'Open audio settings' }, Icon('settings'))
149
150
  )
150
151
  );
151
152
  }
@@ -215,7 +216,7 @@ export function ChatHeader({ icon = '#', name, topic, toolbar = [] } = {}) {
215
216
  export function VoiceStrip({ channelName, status, muted, deafened, onMute, onDeafen, onLeave, open } = {}) {
216
217
  return h('div', { class: 'cm-voice-strip' + (open ? ' open' : ''), role: 'region', 'aria-label': 'voice controls' },
217
218
  h('div', { class: 'cm-vs-label' },
218
- h('span', { class: 'cm-vs-channel' }, '🔊 ' + (channelName || 'voice')),
219
+ h('span', { class: 'cm-vs-channel' }, Icon('speaker'), ' ' + (channelName || 'voice')),
219
220
  h('span', { class: 'cm-vs-status' }, status || 'connected')
220
221
  ),
221
222
  h('button', {
@@ -223,13 +224,13 @@ export function VoiceStrip({ channelName, status, muted, deafened, onMute, onDea
223
224
  title: muted ? 'Unmute' : 'Mute',
224
225
  'aria-label': muted ? 'unmute microphone' : 'mute microphone',
225
226
  'aria-pressed': muted ? 'true' : 'false'
226
- }, muted ? '🔇' : '🎤'),
227
+ }, Icon(muted ? 'mic-off' : 'mic')),
227
228
  h('button', {
228
229
  class: 'cm-vs-btn', type: 'button', onclick: onDeafen,
229
230
  title: deafened ? 'Undeafen' : 'Deafen',
230
231
  'aria-label': deafened ? 'undeafen' : 'deafen',
231
232
  'aria-pressed': deafened ? 'true' : 'false'
232
- }, deafened ? '🔕' : '🎧'),
233
+ }, Icon(deafened ? 'speaker-off' : 'speaker')),
233
234
  h('button', {
234
235
  class: 'cm-vs-btn danger', type: 'button', onclick: onLeave,
235
236
  title: 'Leave voice', 'aria-label': 'leave voice channel'
@@ -242,12 +243,12 @@ export function MobileHeader({ title, onMenu, onMembers } = {}) {
242
243
  h('button', {
243
244
  class: 'cm-mh-btn', type: 'button', onclick: onMenu,
244
245
  title: 'Menu', 'aria-label': 'open navigation menu'
245
- }, ''),
246
+ }, Icon('menu')),
246
247
  h('span', { class: 'cm-mh-title' }, title || ''),
247
248
  h('button', {
248
249
  class: 'cm-mh-btn', type: 'button', onclick: onMembers,
249
250
  title: 'Members', 'aria-label': 'show members'
250
- }, '👥')
251
+ }, Icon('members'))
251
252
  );
252
253
  }
253
254
 
@@ -154,8 +154,15 @@ export function Kpi({ items = [] }) {
154
154
  h('div', { class: 'lbl' }, l))));
155
155
  }
156
156
 
157
- export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothing here yet' }) {
157
+ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothing here yet', rowLabels }) {
158
158
  if (!rows || rows.length === 0) return h('div', { class: 'empty' }, emptyText);
159
+ // rowLabels lets callers supply a plain-text label per row when the first
160
+ // cell is a vnode (so the aria-label is meaningful, not the literal 'row').
161
+ const labelFor = (row, i) => {
162
+ if (Array.isArray(rowLabels) && rowLabels[i] != null) return String(rowLabels[i]);
163
+ const c = row[0];
164
+ return c == null ? 'row' : (typeof c === 'object' ? 'row' : String(c));
165
+ };
159
166
  return h('table', { role: 'table' },
160
167
  h('thead', {}, h('tr', { role: 'row' }, ...headers.map((hd, i) => h('th', { key: i, scope: 'col', role: 'columnheader' }, hd)))),
161
168
  h('tbody', {}, ...rows.map((row, i) => h('tr', {
@@ -163,7 +170,7 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
163
170
  class: onRowClick ? 'clickable' : '',
164
171
  role: 'row',
165
172
  onclick: onRowClick ? () => onRowClick(i) : null,
166
- ...(onRowClick ? { tabindex: '0', onkeydown: (e) => { if (e.key === 'Enter') onRowClick(i); } } : {})
173
+ ...(onRowClick ? { tabindex: '0', 'aria-label': 'open ' + labelFor(row, i), onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(i); } } } : {})
167
174
  }, ...row.map((c, j) => h('td', { key: j, role: 'cell' }, c == null ? '' : (typeof c === 'object' ? c : String(c))))))));
168
175
  }
169
176
 
@@ -235,13 +242,14 @@ export function PageHeader({ title, lede, eyebrow, right }) {
235
242
  );
236
243
  }
237
244
 
238
- export function SearchInput({ value = '', placeholder = 'search…', onInput, onSubmit, name = 'q', key }) {
245
+ export function SearchInput({ value = '', placeholder = 'search…', onInput, onSubmit, name = 'q', key, label }) {
239
246
  return h('input', {
240
247
  key,
241
248
  type: 'search',
242
249
  name,
243
250
  class: 'ds-search-input',
244
251
  placeholder,
252
+ 'aria-label': label || placeholder,
245
253
  value,
246
254
  oninput: onInput ? (e) => onInput(e.target.value, e) : null,
247
255
  onkeydown: onSubmit ? (e) => { if (e.key === 'Enter') onSubmit(e.target.value, e); } : null
@@ -28,6 +28,19 @@ const section = (title, ...children) => Panel({ title, children: children.flat()
28
28
  const noteAlert = (note) => note ? h('div', { class: 'ds-alert ds-alert-' + note.kind, role: 'alert' },
29
29
  h('span', { class: 'ds-alert-icon' }, '!'),
30
30
  h('div', { class: 'ds-alert-content' }, note.msg)) : null;
31
+ // Manual refresh button for non-polling pages — parity with auto-refreshing ones.
32
+ const refreshBtn = (onClick, busy) => Btn({ children: busy ? 'refreshing…' : '↻ refresh', disabled: !!busy, onClick, 'aria-label': 'refresh' });
33
+ // Non-blocking refresh-error banner: keep last-good content, surface the failure.
34
+ const refreshError = (err) => err ? h('div', { class: 'ds-alert ds-alert-warn', role: 'status', 'aria-live': 'polite' },
35
+ h('span', { class: 'ds-alert-icon' }, '!'),
36
+ h('div', { class: 'ds-alert-content' }, 'refresh failed: ' + String(err.message || err))) : null;
37
+ // Polite live region announcing async busy/done state to screen readers.
38
+ const liveRegion = (msg) => h('div', { class: 'fd-sr-live', role: 'status', 'aria-live': 'polite' }, msg || '');
39
+ // Truncate with a title tooltip carrying the full text.
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
+ // Autoscroll a thread only when the user is already near the bottom, so
42
+ // scrolling up to read history is not yanked back down on the next render.
43
+ const stickyScroll = (el) => { if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; if (nearBottom) el.scrollTop = el.scrollHeight; };
31
44
 
32
45
  // ---- home ------------------------------------------------------------------
33
46
 
@@ -37,9 +50,10 @@ export const home = makePage((ctx) => {
37
50
  const [health, agents, sessions] = await Promise.all([
38
51
  api('/api/health').catch(() => null),
39
52
  api('/api/agents').catch(() => null),
40
- api('/api/sessions').catch(() => []),
53
+ api('/api/sessions').catch((e) => ({ _err: e })),
41
54
  ]);
42
- ctx.set({ loading: false, health, agents, sessions: Array.isArray(sessions) ? sessions : [], error: null });
55
+ const sessFailed = sessions && sessions._err;
56
+ ctx.set({ loading: false, health, agents, sessions: Array.isArray(sessions) ? sessions : [], sessFailed, error: null });
43
57
  } catch (e) { ctx.set({ loading: false, error: e }); }
44
58
  }
45
59
  load();
@@ -61,12 +75,14 @@ export const home = makePage((ctx) => {
61
75
  [agents.count ?? 0, 'active agents'],
62
76
  ] }),
63
77
  section('recent sessions',
64
- sessions.length
65
- ? Table({
66
- headers: ['session', 'platform', 'updated'],
67
- rows: sessions.slice(0, 8).map(x => [x.title || x.id, x.platform || '—', fmtAgo(x.updated_at)]),
68
- })
69
- : emptyState('no sessions yet')),
78
+ s.sessFailed
79
+ ? errorState(new Error('could not load sessions'))
80
+ : sessions.length
81
+ ? Table({
82
+ 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)]; }),
84
+ })
85
+ : emptyState('no sessions yet')),
70
86
  section('health',
71
87
  s.health ? Table({ headers: ['check', 'status'], rows: Object.entries(s.health).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)]) })
72
88
  : emptyState('health endpoint unavailable')),
@@ -96,8 +112,9 @@ export const chat = makePage((ctx) => {
96
112
  const s = ctx.state;
97
113
  return h('div', { class: 'fd-chat' },
98
114
  PageHeader({ eyebrow: 'freddie', title: 'chat', lede: 'one-shot agent turns · POST /api/chat' }),
115
+ liveRegion(s.sending ? 'waiting for assistant reply' : ''),
99
116
  h('div', { class: 'chat-thread fd-chat-thread', role: 'log', 'aria-label': 'chat messages',
100
- ref: (el) => { if (el) el.scrollTop = el.scrollHeight; } },
117
+ ref: stickyScroll },
101
118
  s.messages.length ? s.messages.map((m, i) => ChatMessage({ ...m, key: i }))
102
119
  : emptyState('send a prompt to start', '✎'),
103
120
  s.sending ? ChatMessage({ role: 'assistant', typing: true, key: '_typing' }) : null),
@@ -114,11 +131,25 @@ export const chat = makePage((ctx) => {
114
131
  // ---- voice -----------------------------------------------------------------
115
132
 
116
133
  export const voice = makePage((ctx) => {
117
- Object.assign(ctx.state, { loading: false });
118
- return () => [
119
- PageHeader({ eyebrow: 'freddie', title: 'voice', lede: 'voice surfaces' }),
120
- section('status', emptyState('no voice backend wired in this build. configure a transcription/tts plugin to enable.', '🎙')),
121
- ];
134
+ async function load() {
135
+ // Probe for a voice backend; the endpoint is optional, so a 404/!ok
136
+ // means "not wired" rather than an error to surface.
137
+ try { const v = await api('/api/voice').catch(() => null); ctx.set({ loading: false, voice: v, error: null }); }
138
+ catch (e) { ctx.set({ loading: false, error: e }); }
139
+ }
140
+ load();
141
+ return () => {
142
+ const s = ctx.state;
143
+ if (s.loading) return loadingState();
144
+ const v = s.voice;
145
+ const enabled = v && (v.enabled || v.transcription || v.tts);
146
+ return [
147
+ PageHeader({ eyebrow: 'freddie', title: 'voice', lede: 'voice surfaces', right: enabled ? Chip({ tone: 'ok', children: 'enabled' }) : Chip({ tone: 'neutral', children: 'not configured' }) }),
148
+ enabled
149
+ ? section('backends', Table({ headers: ['capability', 'status'], rows: [['transcription', v.transcription ? Chip({ tone: 'ok', children: 'on' }) : Chip({ tone: 'neutral', children: 'off' })], ['tts', v.tts ? Chip({ tone: 'ok', children: 'on' }) : Chip({ tone: 'neutral', children: 'off' })]] }))
150
+ : section('status', emptyState('no voice backend wired in this build. configure a transcription/tts plugin to enable.', '🎙')),
151
+ ];
152
+ };
122
153
  });
123
154
 
124
155
  // ---- sessions --------------------------------------------------------------
@@ -134,6 +165,7 @@ export const sessions = makePage((ctx) => {
134
165
  try { ctx.set({ loading: false, list: await api('/api/search?q=' + encodeURIComponent(q)), error: null }); }
135
166
  catch (e) { ctx.set({ loading: false, error: e }); }
136
167
  }
168
+ async function refresh() { ctx.set({ refreshing: true }); try { ctx.set({ list: await api('/api/sessions'), error: null }); } catch (e) { ctx.set({ error: e }); } ctx.set({ refreshing: false }); }
137
169
  async function open(id) {
138
170
  ctx.set({ selected: id, msgLoading: true });
139
171
  try { ctx.set({ messages: await api('/api/sessions/' + encodeURIComponent(id) + '/messages'), msgLoading: false }); }
@@ -146,12 +178,14 @@ export const sessions = makePage((ctx) => {
146
178
  if (s.error && !s.list) return errorState(s.error, load);
147
179
  const list = Array.isArray(s.list) ? s.list : [];
148
180
  return [
149
- PageHeader({ eyebrow: 'freddie', title: 'sessions', lede: list.length + ' sessions' }),
150
- SearchInput({ value: s.q, placeholder: 'search messages…', onInput: (v) => { s.q = v; }, onSubmit: (v) => search(v) }),
181
+ PageHeader({ eyebrow: 'freddie', title: 'sessions', lede: list.length + ' sessions', right: refreshBtn(refresh, s.refreshing) }),
182
+ s.error && s.list ? refreshError(s.error) : null,
183
+ SearchInput({ value: s.q, label: 'search sessions', placeholder: 'search messages…', onInput: (v) => { s.q = v; }, onSubmit: (v) => search(v) }),
151
184
  section('sessions',
152
185
  list.length
153
186
  ? Table({ headers: ['session', 'platform', 'updated'], onRowClick: (i) => open(list[i].id),
154
- rows: list.map(x => [x.title || x.id, x.platform || '—', fmtAgo(x.updated_at)]) })
187
+ 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)]; }) })
155
189
  : emptyState('no sessions match')),
156
190
  s.selected ? section('messages · ' + s.selected,
157
191
  s.msgLoading ? loadingState()
@@ -282,6 +316,7 @@ export const models = makePage((ctx) => {
282
316
  const status = s.sampler?.status || {};
283
317
  return [
284
318
  PageHeader({ eyebrow: 'freddie', title: 'models', lede: providers.length + ' providers', right: Btn({ primary: true, disabled: s.discovering, children: s.discovering ? 'discovering…' : 'discover', onClick: discover }) }),
319
+ liveRegion(s.discovering ? 'discovering models' : ''),
285
320
  section('providers', providers.length ? Table({
286
321
  headers: ['provider', 'sampler', 'cached models'],
287
322
  rows: providers.map(p => {
@@ -381,11 +416,16 @@ export const config = makePage((ctx) => {
381
416
  if (s.error) return errorState(s.error, load);
382
417
  const cfg = s.cfg || {};
383
418
  const flat = Object.entries(cfg).filter(([, v]) => typeof v !== 'object' || v === null);
419
+ const nested = Object.entries(cfg).filter(([, v]) => typeof v === 'object' && v !== null);
384
420
  const skinList = Array.isArray(s.skins) ? s.skins : (s.skins?.skins || s.skins?.available || []);
385
421
  const activeSkin = cfg.skin || s.skins?.active || '';
386
422
  return [
387
423
  PageHeader({ eyebrow: 'freddie', title: 'config', lede: 'runtime configuration' }),
388
424
  noteAlert(s.note),
425
+ liveRegion(s.busy ? 'saving configuration' : ''),
426
+ nested.length ? h('div', { class: 'ds-alert ds-alert-info', role: 'note' },
427
+ h('span', { class: 'ds-alert-icon' }, 'i'),
428
+ h('div', { class: 'ds-alert-content' }, nested.length + ' nested config ' + (nested.length === 1 ? 'object is' : 'objects are') + ' read-only here (' + nested.map(([k]) => k).join(', ') + ') — edit via the config file or raw view below.')) : null,
389
429
  skinList.length ? section('skin',
390
430
  Select({ label: 'active skin', value: activeSkin, options: skinList, onChange: (v) => setSkin(v) })
391
431
  ) : null,
@@ -463,7 +503,19 @@ export const batch = makePage((ctx) => {
463
503
  TextField({ label: 'prompts (one per line)', value: s.prompts, multiline: true, rows: 6, onInput: (v) => { s.prompts = v; } }),
464
504
  TextField({ label: 'concurrency', type: 'number', value: String(s.concurrency), onInput: (v) => { s.concurrency = v; } }),
465
505
  Btn({ primary: true, disabled: s.busy, children: s.busy ? 'running…' : 'run batch', onClick: run })),
466
- s.result ? section('result', h('pre', { class: 'fd-pre' }, JSON.stringify(s.result, null, 2))) : null,
506
+ s.result ? section('result', (() => {
507
+ const r = s.result;
508
+ const items = Array.isArray(r.results) ? r.results : (Array.isArray(r) ? r : null);
509
+ if (!items) return h('pre', { class: 'fd-pre' }, JSON.stringify(r, null, 2));
510
+ return [
511
+ Kpi({ items: [[items.length, 'prompts'], [items.filter(x => !x.error).length, 'ok'], [items.filter(x => x.error).length, 'errors']] }),
512
+ 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)];
516
+ }) }),
517
+ ];
518
+ })()) : null,
467
519
  ];
468
520
  };
469
521
  });
@@ -65,6 +65,32 @@ export function Glyph({ children, color, size = 'base' }) {
65
65
  return h('span', { class: 'glyph', style }, children);
66
66
  }
67
67
 
68
+ // Monochrome inline-SVG icons (stroke=currentColor) so chrome reads as one
69
+ // coherent line-icon set instead of multicolor OS emoji. 16px box, 1.6 stroke.
70
+ const ICON_PATHS = {
71
+ mic: '<path d="M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
72
+ 'mic-off': '<path d="M9 9v2a3 3 0 0 0 4.5 2.6M15 11V6a3 3 0 0 0-5.9-.8"/><path d="M5 11a7 7 0 0 0 11.5 5.4M12 18v3"/><path d="m4 4 16 16"/>',
73
+ speaker: '<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/>',
74
+ 'speaker-off': '<path d="M11 5 6 9H3v6h3l5 4z"/><path d="m17 9 4 6M21 9l-4 6"/>',
75
+ camera: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',
76
+ screen: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
77
+ phone: '<path d="M5 4h3l2 5-2 1a11 11 0 0 0 5 5l1-2 5 2v3a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z"/>',
78
+ members: '<circle cx="9" cy="8" r="3"/><path d="M3 20a6 6 0 0 1 12 0M16 6a3 3 0 0 1 0 6M21 20a6 6 0 0 0-4-5.7"/>',
79
+ menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
80
+ settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1"/>'
81
+ };
82
+ export function Icon(name, { size = 16 } = {}) {
83
+ const inner = ICON_PATHS[name];
84
+ if (!inner) return h('span', { class: 'glyph', 'aria-hidden': 'true' }, '');
85
+ return h('svg', {
86
+ class: 'ds-icon ds-icon-' + name,
87
+ width: String(size), height: String(size), viewBox: '0 0 24 24',
88
+ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6',
89
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
90
+ dangerouslySetInnerHTML: { __html: inner }
91
+ });
92
+ }
93
+
68
94
  export function Topbar({ brand = '247420', leaf = '', items = [], active = '', onNav, search } = {}) {
69
95
  return h('header', { class: 'app-topbar', role: 'banner' },
70
96
  Brand({ name: brand, leaf }),
@@ -151,7 +177,7 @@ export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
151
177
  class: 'app-side-toggle', type: 'button',
152
178
  'aria-label': 'toggle navigation', 'aria-expanded': 'false', 'aria-controls': 'app-main',
153
179
  onclick: () => toggleSide(),
154
- }, '') : null,
180
+ }, Icon('menu')) : null,
155
181
  topbar || null,
156
182
  crumb || null,
157
183
  h('div', { class: 'app-body' + (hasSide ? '' : ' no-side') },
@@ -2,6 +2,7 @@
2
2
  // Pure factories returning webjsx vnodes. Class prefix: vx-*.
3
3
 
4
4
  import * as webjsx from '../../vendor/webjsx/index.js';
5
+ import { Icon } from './shell.js';
5
6
  const h = webjsx.createElement;
6
7
 
7
8
  function fmtDur(s) {
@@ -32,7 +33,7 @@ export function PttButton({ state = 'idle', mode = 'ptt', onHoldStart, onHoldEnd
32
33
  ontouchend: (e) => { e.preventDefault(); end(e); }
33
34
  },
34
35
  h('span', { class: 'vx-ptt-glow', 'aria-hidden': 'true' }),
35
- h('span', { class: 'vx-ptt-icon', 'aria-hidden': 'true' }, state === 'idle' ? '🎙' : '●'),
36
+ h('span', { class: 'vx-ptt-icon', 'aria-hidden': 'true' }, state === 'idle' ? Icon('mic') : '●'),
36
37
  h('span', { class: 'vx-ptt-label' }, label)
37
38
  );
38
39
  }
@@ -74,7 +75,7 @@ export function WebcamPreview({ videoStream = null, resolution = '640x480', fps
74
75
  h('div', { class: 'vx-cam-stage' },
75
76
  enabled
76
77
  ? h('video', { class: 'vx-cam-video', ref: videoRef, autoplay: true, muted: true, playsinline: true })
77
- : h('div', { class: 'vx-cam-placeholder' }, h('span', {}, '📷'), h('span', {}, 'Camera off'))
78
+ : h('div', { class: 'vx-cam-placeholder' }, h('span', {}, Icon('camera')), h('span', {}, 'Camera off'))
78
79
  ),
79
80
  h('div', { class: 'vx-cam-controls' },
80
81
  h('select', {
@@ -205,15 +206,15 @@ export function VoiceControls({ muted = false, deafened = false, cameraOn = fals
205
206
  h('span', { class: 'vx-vc-glyph', 'aria-hidden': 'true' }, glyph)
206
207
  );
207
208
  return h('div', { class: 'vx-vc', role: 'toolbar', 'aria-label': 'voice controls' },
208
- btn('vx-vc-mic', !muted, muted ? 'Unmute' : 'Mute', muted ? '🔇' : '🎙', onMic),
209
- btn('vx-vc-deafen', !deafened, deafened ? 'Undeafen' : 'Deafen', deafened ? '🔕' : '🔊', onDeafen),
210
- btn('vx-vc-camera', cameraOn, cameraOn ? 'Stop camera' : 'Start camera', '📷', onCamera),
211
- btn('vx-vc-screen', screenShareOn, screenShareOn ? 'Stop sharing' : 'Share screen', '🖥', onScreenShare),
212
- btn('vx-vc-settings', false, 'Voice settings', '', onSettings),
209
+ btn('vx-vc-mic', !muted, muted ? 'Unmute' : 'Mute', Icon(muted ? 'mic-off' : 'mic'), onMic),
210
+ btn('vx-vc-deafen', !deafened, deafened ? 'Undeafen' : 'Deafen', Icon(deafened ? 'speaker-off' : 'speaker'), onDeafen),
211
+ btn('vx-vc-camera', cameraOn, cameraOn ? 'Stop camera' : 'Start camera', Icon('camera'), onCamera),
212
+ btn('vx-vc-screen', screenShareOn, screenShareOn ? 'Stop sharing' : 'Share screen', Icon('screen'), onScreenShare),
213
+ btn('vx-vc-settings', false, 'Voice settings', Icon('settings'), onSettings),
213
214
  h('button', {
214
215
  type: 'button', class: 'vx-vc-btn vx-vc-leave', 'aria-label': 'Leave voice', title: 'Leave voice',
215
216
  onclick: onLeave ? (e) => onLeave(e) : null
216
- }, h('span', { class: 'vx-vc-glyph', 'aria-hidden': 'true' }, '📞'))
217
+ }, h('span', { class: 'vx-vc-glyph', 'aria-hidden': 'true' }, Icon('phone')))
217
218
  );
218
219
  }
219
220
 
package/src/components.js CHANGED
@@ -4,7 +4,7 @@ import * as webjsx from '../vendor/webjsx/index.js';
4
4
  export const h = webjsx.createElement;
5
5
 
6
6
  export {
7
- Brand, Chip, Btn, Glyph, IconButton, Badge,
7
+ Brand, Chip, Btn, Glyph, Icon, IconButton, Badge,
8
8
  Topbar, Crumb, Side, Status, AppShell,
9
9
  Heading, Lede, Dot, Rail
10
10
  } from './components/shell.js';
@@ -55,6 +55,11 @@ html, body {
55
55
  .os-menubar > *, .os-taskbar > * { flex-shrink: 0; }
56
56
  .os-menubar .os-spacer { flex: 1 1 auto; min-width: 0; }
57
57
  .os-menubar .os-tray { margin-left: auto; }
58
+ /* Many open windows must scroll within the bar, not overflow it. Mobile
59
+ already had this; promote it to all widths so a crowded desktop taskbar
60
+ scrolls horizontally instead of pushing buttons off-screen. */
61
+ .os-taskbar { overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
62
+ .os-taskbar::-webkit-scrollbar { display: none; }
58
63
 
59
64
  .os-brand {
60
65
  color: var(--os-fg);
@@ -738,3 +743,26 @@ html.ds-247420 { touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-t
738
743
  .ds-247420 .tb-sessions-card-actions button:hover { background: color-mix(in oklab, var(--fg, #1a1a1a) 6%, transparent); }
739
744
  .ds-247420 .tb-sessions-card-actions button.danger:hover { background: #d33; color: #fff; border-color: #d33; }
740
745
  .ds-247420 .tb-sessions-empty-mid { padding: 40px; text-align: center; opacity: 0.6; font-size: 13px; }
746
+
747
+ /* ---- prefers-contrast: more — strengthen borders + focus so the translucent
748
+ chrome stays legible under forced/high-contrast user settings. ---- */
749
+ @media (prefers-contrast: more) {
750
+ .ds-247420 .wm-win { border-color: var(--fg, #1a1a1a); }
751
+ .ds-247420 .wm-bar { border-bottom: 1px solid var(--fg, #1a1a1a); }
752
+ .ds-247420 .os-menubar,
753
+ .ds-247420 .os-taskbar { border-color: var(--fg, #1a1a1a); }
754
+ .ds-247420 .tb-sess-chip,
755
+ .ds-247420 .tb-sessions-card,
756
+ .ds-247420 .tb-sessions-btn { border-color: var(--fg, #1a1a1a); }
757
+ .ds-247420 :focus-visible { outline: 2px solid var(--fg, #1a1a1a); outline-offset: 2px; }
758
+ }
759
+
760
+ /* ---- print: a web-OS desktop has no meaningful print form. Suppress the
761
+ live chrome so a Ctrl+P does not waste pages on translucent panels. ---- */
762
+ @media print {
763
+ .os-menubar, .os-taskbar, .wm-snap-preview,
764
+ .tb-sess-overlay, .tb-switching, .wm-switcher { display: none !important; }
765
+ .wm-root, .wm-canvas { position: static !important; transform: none !important; overflow: visible !important; }
766
+ .wm-win { position: static !important; box-shadow: none !important; border: 1px solid #000 !important; page-break-inside: avoid; margin: 0 0 12px; width: auto !important; height: auto !important; }
767
+ .wm-win.wm-min { display: none !important; }
768
+ }
@@ -25,6 +25,12 @@
25
25
  overflow: hidden;
26
26
  }
27
27
  .wm-win.wm-focused { box-shadow: inset 4px 0 0 var(--os-accent); }
28
+ /* Promote to a compositor layer only for the duration of a drag/resize so the
29
+ per-frame left/top/width/height writes don't force a main-thread layout each
30
+ pointermove. Released on pointerup (class removed) to avoid the permanent
31
+ will-change memory/perf anti-pattern. */
32
+ .wm-win.wm-dragging { will-change: left, top; }
33
+ .wm-win.wm-resizing { will-change: width, height; }
28
34
 
29
35
  .wm-bar {
30
36
  display: flex;