anentrypoint-design 0.0.170 → 0.0.171

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.170",
3
+ "version": "0.0.171",
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",
@@ -29,6 +29,7 @@
29
29
 
30
30
  import * as webjsx from '../vendor/webjsx/index.js';
31
31
  import { Icon } from './components/shell.js';
32
+ import { register } from './debug.js';
32
33
  import { Chat, ChatComposer } from './components/chat.js';
33
34
  import {
34
35
  ServerRail, ChannelItem, MemberList, MobileHeader,
@@ -85,11 +86,11 @@ export function mountCommunityApp(root, adapter = {}) {
85
86
  const railPill = (c, cur, isVoice, s) => {
86
87
  const active = cur.id === c.id;
87
88
  const inVoice = isVoice && s.voiceConnected && s.voiceChannelName === c.name;
88
- const glyph = inVoice ? h('span', { class: 'glyph' }, '')
89
- : (c.type === 'threaded' ? h('span', { class: 'glyph' }, '')
90
- : h('span', { class: 'glyph' }, Icon(CHANNEL_ICON[c.type] || 'hash', { size: 15 })));
89
+ const glyph = inVoice ? h('span', { class: 'glyph', 'aria-hidden': 'true' }, h('span', { class: 'ds-dot ds-dot-live' }))
90
+ : (c.type === 'threaded' ? h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('circle-dot', { size: 15 }))
91
+ : h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(CHANNEL_ICON[c.type] || 'hash', { size: 15 })));
91
92
  return h('a', {
92
- href: '#', class: active ? 'active' : '',
93
+ href: '#', class: active ? 'active' : '', 'aria-label': (c.name || c.id) + (inVoice ? ' (in voice)' : ''),
93
94
  onclick: (e) => { e.preventDefault(); A.switchChannel && A.switchChannel(c); },
94
95
  oncontextmenu: (e) => { e.preventDefault(); A.channelContext && A.channelContext(c.id, e.clientX, e.clientY); },
95
96
  }, glyph, h('span', {}, c.name || c.id),
@@ -99,10 +100,10 @@ export function mountCommunityApp(root, adapter = {}) {
99
100
  const railServerPill = (sv, s) => {
100
101
  const active = sv._home ? s.homeMode : (!s.homeMode && s.currentServerId === sv.id);
101
102
  return h('a', {
102
- href: '#', class: active ? 'active' : '',
103
+ href: '#', class: active ? 'active' : '', 'aria-label': sv._home ? 'home' : (sv.name || sv.id),
103
104
  onclick: (e) => { e.preventDefault(); sv._home ? (A.goHome && A.goHome()) : (A.switchServer && A.switchServer(sv.id)); },
104
105
  oncontextmenu: sv._home ? null : (e) => { e.preventDefault(); A.serverContext && A.serverContext(sv.id, e.clientX, e.clientY); },
105
- }, h('span', { class: 'glyph' }, sv._home ? '' : (sv.name || '?').slice(0, 1).toUpperCase()),
106
+ }, h('span', { class: 'glyph', 'aria-hidden': 'true' }, sv._home ? Icon('square', { size: 15 }) : (sv.name || '?').slice(0, 1).toUpperCase()),
106
107
  h('span', {}, sv.name || sv.id),
107
108
  sv.unreadCount ? h('span', { class: 'count' }, sv.unreadCount > 99 ? '99+' : String(sv.unreadCount)) : null);
108
109
  };
@@ -228,6 +229,21 @@ export function mountCommunityApp(root, adapter = {}) {
228
229
 
229
230
  let unsub = null;
230
231
  if (typeof adapter.subscribe === 'function') unsub = adapter.subscribe(render);
232
+
233
+ // Observability: expose live overlay + snapshot state for in-browser inspection.
234
+ register('community-app', () => {
235
+ const s = get() || {};
236
+ return {
237
+ overlays: { context: ctx.open, emoji: emoji.open, palette: palette.open },
238
+ channels: (s.channels || []).length,
239
+ servers: (s.servers || []).length,
240
+ messages: (s.messages || []).length,
241
+ currentChannel: (s.currentChannel || {}).name || null,
242
+ voiceConnected: !!s.voiceConnected,
243
+ homeMode: !!s.homeMode,
244
+ };
245
+ });
246
+
231
247
  render();
232
248
  return { render, api, destroy: () => { if (unsub) try { unsub(); } catch (_) {} } };
233
249
  }
@@ -11,36 +11,16 @@
11
11
  // The host owns state; AgentChat renders it and calls back on intent.
12
12
 
13
13
  import * as webjsx from '../../vendor/webjsx/index.js';
14
- import { ChatComposer, ChatMessage } from './chat.js';
14
+ import { ChatComposer, ChatMessage, makeThreadAutoScroll } from './chat.js';
15
15
  import { Select } from './content.js';
16
16
  import { Btn } from './shell.js';
17
17
 
18
18
  const h = webjsx.createElement;
19
19
 
20
- // Auto-scroll a thread to the bottom while the user is already near the bottom,
21
- // via an IntersectionObserver on a sentinel the AICat scroll behaviour, lifted
22
- // so it works for any message list without a per-frame scrollTop write.
23
- function threadRef(msgCount) {
24
- return (el) => {
25
- if (!el) return;
26
- let sentinel = el.querySelector('[data-scroll-sentinel]');
27
- if (!sentinel) {
28
- sentinel = document.createElement('div');
29
- sentinel.setAttribute('data-scroll-sentinel', '');
30
- sentinel.style.height = '1px';
31
- el.appendChild(sentinel);
32
- }
33
- const obs = new IntersectionObserver((entries) => {
34
- if (entries[0]?.isIntersecting && el.dataset.msgCount !== String(msgCount)) {
35
- el.scrollTop = el.scrollHeight - el.clientHeight;
36
- el.dataset.msgCount = String(msgCount);
37
- }
38
- }, { root: el, threshold: 0 });
39
- obs.observe(sentinel);
40
- el.dataset.msgCount = String(msgCount);
41
- return () => obs.disconnect();
42
- };
43
- }
20
+ // Auto-scroll behaviour is the shared chat helper; bind it to this thread's
21
+ // live message count. (`makeThreadAutoScroll` takes a getter so the observer
22
+ // always compares against current state, not a value captured at mount.)
23
+ const threadRef = (msgCount) => makeThreadAutoScroll(() => msgCount);
44
24
 
45
25
  // The agent picker: agent-then-model, not a flat model list. Unavailable agents
46
26
  // are disabled (unless installable via npx). Ordering is the host's concern.
@@ -38,10 +38,45 @@ export function renderInline(text) {
38
38
  return out;
39
39
  }
40
40
 
41
- const FILE_GLYPHS = { pdf: '▤', zip: '▦', tar: '▦', gz: '▦', mp4: '▶', mp3: '♪', wav: '♪', csv: '⊞', json: '{}', md: '§', txt: '§', default: '◫' };
42
- function fileGlyph(name) {
41
+ // Map file extension -> line-icon name (drawn SVG, not a decorative glyph).
42
+ const FILE_ICONS = { pdf: 'file-pdf', zip: 'file-zip', tar: 'file-zip', gz: 'file-zip', mp4: 'file-video', mov: 'file-video', mp3: 'file-audio', wav: 'file-audio', csv: 'file-sheet', json: 'file-code', js: 'file-code', ts: 'file-code', md: 'file-text', txt: 'file-text' };
43
+ function fileIconName(name) {
43
44
  const ext = String(name || '').split('.').pop().toLowerCase();
44
- return FILE_GLYPHS[ext] || FILE_GLYPHS.default;
45
+ return FILE_ICONS[ext] || 'file';
46
+ }
47
+
48
+ // Eagerly warm the markdown + Prism caches on first chat-surface mount, once.
49
+ function ensureCachesInit() {
50
+ if (_cacheInitialized) return;
51
+ _cacheInitialized = true;
52
+ initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
53
+ }
54
+
55
+ // Build a ref callback that keeps a scroll container pinned to the bottom when
56
+ // new messages arrive AND the user is already at the bottom (sentinel visible).
57
+ // `getCount` returns the current message count so the observer compares against
58
+ // live state. Shared by Chat, AICat, and AgentChat.
59
+ export function makeThreadAutoScroll(getCount) {
60
+ return (el) => {
61
+ if (!el) return;
62
+ let sentinel = el.querySelector('[data-scroll-sentinel]');
63
+ if (!sentinel) {
64
+ sentinel = document.createElement('div');
65
+ sentinel.setAttribute('data-scroll-sentinel', '');
66
+ sentinel.style.height = '1px';
67
+ el.appendChild(sentinel);
68
+ }
69
+ const obs = new IntersectionObserver((entries) => {
70
+ const count = String(getCount());
71
+ if (entries[0]?.isIntersecting && el.dataset.msgCount !== count) {
72
+ el.scrollTop = el.scrollHeight - el.clientHeight;
73
+ el.dataset.msgCount = count;
74
+ }
75
+ }, { root: el, threshold: 0 });
76
+ obs.observe(sentinel);
77
+ el.dataset.msgCount = String(getCount());
78
+ return () => obs.disconnect();
79
+ };
45
80
  }
46
81
 
47
82
  function MdNode(p) {
@@ -79,19 +114,19 @@ const PART_RENDERERS = {
79
114
  p.caption ? h('span', { class: 'cap' }, p.caption) : null),
80
115
  pdf: (p) => h('div', { class: 'chat-pdf' },
81
116
  h('div', { class: 'chat-pdf-head' },
82
- h('span', { class: 'glyph', 'aria-hidden': 'true' }, ''),
117
+ h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
83
118
  h('span', { class: 'name' }, p.name || 'document.pdf'),
84
119
  p.size != null ? h('span', { class: 'size' }, fmtBytes(p.size)) : null,
85
120
  h('a', { class: 'open', href: p.src, target: '_blank', rel: 'noopener', 'aria-label': `open PDF: ${p.name || 'document.pdf'}` }, 'open ->')
86
121
  ),
87
122
  h('embed', { src: p.src, type: 'application/pdf', 'aria-label': `PDF document: ${p.name || 'document.pdf'}` })),
88
123
  file: (p) => h('a', { class: 'chat-file', href: p.src, target: '_blank', rel: 'noopener', download: p.name || true, 'aria-label': `download file: ${p.name || 'attachment'} (${p.kindLabel || (p.name || '').split('.').pop().toUpperCase()})` },
89
- h('span', { class: 'glyph', 'aria-hidden': 'true' }, fileGlyph(p.name)),
124
+ h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(fileIconName(p.name), { size: 22 })),
90
125
  h('span', { class: 'meta' },
91
126
  h('span', { class: 'name' }, p.name || 'attachment'),
92
127
  h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
93
128
  ),
94
- h('span', { class: 'go', 'aria-hidden': 'true' }, '')),
129
+ h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
95
130
  link: (p) => h('a', { class: 'chat-link', href: p.href, target: '_blank', rel: 'noopener', 'aria-label': `link: ${p.title || p.href}` },
96
131
  p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
97
132
  h('span', { class: 'meta' },
@@ -116,7 +151,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
116
151
  const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '');
117
152
  const fallbackAvatar = avatar != null
118
153
  ? avatar
119
- : (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '' : ''));
154
+ : (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?'));
120
155
  const av = h('span', { class: 'chat-avatar' }, fallbackAvatar);
121
156
  let bodyNodes;
122
157
  if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
@@ -128,7 +163,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
128
163
  h('span', { class: 'e', 'aria-hidden': 'true' }, r.emoji), h('span', { class: 'n', 'aria-hidden': 'true' }, String(r.count)))))
129
164
  : null;
130
165
  const tickNode = resolvedWho === 'you' && receipt
131
- ? h('span', { class: 'tick' + (receipt === 'read' ? ' read' : ''), 'aria-label': receipt === 'read' ? 'message read' : 'message sent' }, receipt === 'read' ? '✓✓' : '')
166
+ ? h('span', { class: 'tick' + (receipt === 'read' ? ' read' : ''), role: 'img', 'aria-label': receipt === 'read' ? 'message read' : 'message sent' }, Icon(receipt === 'read' ? 'check-check' : 'check', { size: 14 }))
132
167
  : null;
133
168
  const metaItems = [];
134
169
  if (name && resolvedWho === 'them') metaItems.push(h('span', { class: 'who', key: 'w' }, name));
@@ -191,40 +226,9 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
191
226
  }
192
227
 
193
228
  export function Chat({ title = 'chat', sub, messages = [], composer, header } = {}) {
194
- // Eagerly initialize markdown and Prism caches on first Chat component mount.
195
- // This parallelizes library loading, avoiding per-message delays.
196
- if (!_cacheInitialized) {
197
- _cacheInitialized = true;
198
- initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
199
- }
200
-
201
- const threadRef = (el) => {
202
- if (!el) return;
203
- const sentinel = el.querySelector('[data-scroll-sentinel]') || (() => {
204
- const s = h('div', { 'data-scroll-sentinel': true });
205
- const vnode = { props: s.props };
206
- const sentinelEl = document.createElement('div');
207
- sentinelEl.setAttribute('data-scroll-sentinel', '');
208
- sentinelEl.style.height = '1px';
209
- el.appendChild(sentinelEl);
210
- return sentinelEl;
211
- })();
212
-
213
- // Use IntersectionObserver to detect if user is at bottom
214
- const obs = new IntersectionObserver(
215
- (entries) => {
216
- if (entries[0]?.isIntersecting && el.dataset.msgCount !== String(messages.length)) {
217
- el.scrollTop = el.scrollHeight - el.clientHeight;
218
- el.dataset.msgCount = String(messages.length);
219
- }
220
- },
221
- { root: el, threshold: 0 }
222
- );
223
-
224
- obs.observe(sentinel);
225
- el.dataset.msgCount = String(messages.length);
226
- return () => obs.disconnect();
227
- };
229
+ // Warm markdown/Prism caches once so library loading parallelizes.
230
+ ensureCachesInit();
231
+ const threadRef = makeThreadAutoScroll(() => messages.length);
228
232
  const msgCount = messages.length;
229
233
  return h('div', { class: 'chat' },
230
234
  header || h('div', { class: 'chat-head', role: 'banner' },
@@ -255,47 +259,19 @@ export function AICatPortrait({ name = 'aicat', status = 'idle', face } = {}) {
255
259
  h('pre', { class: 'aicat-face', 'aria-label': `${name} portrait` }, face || AICAT_FACE),
256
260
  h('div', { class: 'aicat-meta' },
257
261
  h('span', { class: 'name' }, name),
258
- h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot', 'aria-hidden': 'true' }, ' '), status)
262
+ h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot ds-dot ds-dot-on', 'aria-hidden': 'true' }), ' ', status)
259
263
  )
260
264
  );
261
265
  }
262
266
 
263
267
  export function AICat({ name = 'aicat', messages = [], thinking, composer, status = 'online · purring' } = {}) {
264
- // Eagerly initialize markdown and Prism caches on first AICat component mount.
265
- if (!_cacheInitialized) {
266
- _cacheInitialized = true;
267
- initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
268
- }
269
-
268
+ ensureCachesInit();
270
269
  const annotated = messages.map((m) =>
271
270
  m.who === 'them' ? { ...m, aicat: true, avatar: m.avatar || '=^.^=' } : m);
272
271
  const all = thinking
273
272
  ? [...annotated, { who: 'them', aicat: true, avatar: '=^.^=', typing: true, key: '_thinking' }]
274
273
  : annotated;
275
- const threadRef = (el) => {
276
- if (!el) return;
277
- const sentinel = el.querySelector('[data-scroll-sentinel]') || (() => {
278
- const sentinelEl = document.createElement('div');
279
- sentinelEl.setAttribute('data-scroll-sentinel', '');
280
- sentinelEl.style.height = '1px';
281
- el.appendChild(sentinelEl);
282
- return sentinelEl;
283
- })();
284
-
285
- const obs = new IntersectionObserver(
286
- (entries) => {
287
- if (entries[0]?.isIntersecting && el.dataset.msgCount !== String(all.length)) {
288
- el.scrollTop = el.scrollHeight - el.clientHeight;
289
- el.dataset.msgCount = String(all.length);
290
- }
291
- },
292
- { root: el, threshold: 0 }
293
- );
294
-
295
- obs.observe(sentinel);
296
- el.dataset.msgCount = String(all.length);
297
- return () => obs.disconnect();
298
- };
274
+ const threadRef = makeThreadAutoScroll(() => all.length);
299
275
  return h('div', { class: 'chat' },
300
276
  h('div', { class: 'chat-head', role: 'banner' },
301
277
  h('span', { class: 'dot', 'aria-hidden': 'true' }),
@@ -4,6 +4,10 @@ import * as webjsx from '../../vendor/webjsx/index.js';
4
4
  import { Icon } from './shell.js';
5
5
  const h = webjsx.createElement;
6
6
 
7
+ // Clamp a count to a compact badge string (matches the rail's 99+ convention),
8
+ // so a runaway number never blows out a fixed-width badge or item row.
9
+ const clampCount = (n) => { const v = Number(n) || 0; return v > 99 ? '99+' : String(v); };
10
+
7
11
  export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
8
12
  const initials = (name || '?').slice(0, 2).toUpperCase();
9
13
  return h('div', {
@@ -29,7 +33,7 @@ export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
29
33
 
30
34
  export function ServerRail({ servers = [], activeId, onSelect, onAdd } = {}) {
31
35
  return h('div', { class: 'cm-server-rail', role: 'navigation', 'aria-label': 'servers' },
32
- h('a', { class: 'cm-server-back', href: '../', title: 'Back', 'aria-label': 'back' }, ''),
36
+ h('a', { class: 'cm-server-back', href: '../', title: 'Back', 'aria-label': 'back' }, Icon('chevron-left')),
33
37
  h('div', { class: 'cm-server-sep', 'aria-hidden': 'true' }),
34
38
  ...servers.map(s => ServerIcon({ ...s, active: s.id === activeId, onClick: () => onSelect && onSelect(s.id) })),
35
39
  onAdd ? h('button', { class: 'cm-server-add', type: 'button', onclick: onAdd, title: 'Add server', 'aria-label': 'add server' }, '+') : null
@@ -161,6 +165,9 @@ export function ChannelSidebar({ serverName, channels = [], categories = [], act
161
165
  h('span', { class: 'cm-server-header-name' }, serverName || 'Server'),
162
166
  ),
163
167
  h('div', { class: 'cm-channel-list' },
168
+ (sorted.length === 0 && uncategorized.length === 0)
169
+ ? h('div', { class: 'cm-channel-empty' }, 'no channels yet')
170
+ : null,
164
171
  ...sorted.map(cat => ChannelCategory({
165
172
  id: cat.id,
166
173
  name: cat.name,
@@ -197,7 +204,11 @@ export function MemberItem({ identity, name, color, status = 'online' } = {}) {
197
204
  }
198
205
 
199
206
  export function MemberList({ categories = [], open } = {}) {
207
+ const total = categories.reduce((n, cat) => n + (cat.members ? cat.members.length : 0), 0);
200
208
  return h('div', { class: 'cm-member-list' + (open ? ' open' : '') },
209
+ total === 0
210
+ ? h('div', { key: '_empty', class: 'cm-member-empty' }, 'no members')
211
+ : null,
201
212
  ...categories.flatMap(cat => [
202
213
  h('div', { class: 'cm-member-category', key: cat.label }, `${cat.label} — ${cat.members.length}`),
203
214
  ...cat.members.map((m, i) => MemberItem({ ...m, key: m.identity || i }))
@@ -235,7 +246,7 @@ export function VoiceStrip({ channelName, status, muted, deafened, onMute, onDea
235
246
  h('button', {
236
247
  class: 'cm-vs-btn danger', type: 'button', onclick: onLeave,
237
248
  title: 'Leave voice', 'aria-label': 'leave voice channel'
238
- }, '')
249
+ }, Icon('x'))
239
250
  );
240
251
  }
241
252
 
@@ -266,7 +277,7 @@ export function ReplyBar({ quotedMessage, quotedAuthor, onCancel } = {}) {
266
277
  h('button', {
267
278
  class: 'cm-rb-cancel', type: 'button', onclick: onCancel,
268
279
  title: 'Cancel reply', 'aria-label': 'cancel reply'
269
- }, '')
280
+ }, Icon('x'))
270
281
  );
271
282
  }
272
283
 
@@ -305,7 +316,7 @@ export function ThreadPanel({ threads = [], activeId = null, title = 'Threads',
305
316
  h('span', { class: 'cm-tp-title' }, title),
306
317
  h('div', { class: 'cm-tp-head-actions' },
307
318
  onCreate ? h('button', { type: 'button', class: 'cm-tp-new', 'aria-label': 'new thread', title: 'New thread', onclick: onCreate }, '+') : null,
308
- onClose ? h('button', { type: 'button', class: 'cm-tp-close', 'aria-label': 'close', title: 'Close', onclick: onClose }, '') : null
319
+ onClose ? h('button', { type: 'button', class: 'cm-tp-close', 'aria-label': 'close', title: 'Close', onclick: onClose }, Icon('x')) : null
309
320
  )
310
321
  ),
311
322
  h('div', { class: 'cm-tp-list' },
@@ -355,7 +366,7 @@ export function ForumView({ posts = [], onSearch, onSort, onSelect, onNewPost }
355
366
  },
356
367
  h('div', { class: 'cm-forum-item-head' },
357
368
  h('span', { class: 'cm-forum-item-title' }, p.title || '(untitled)'),
358
- h('span', { class: 'cm-forum-item-replies' }, (Number(p.replyCount) || 0) + ' ▸')
369
+ h('span', { class: 'cm-forum-item-replies' }, clampCount(p.replyCount), Icon('chevron-right', { size: 13 }))
359
370
  ),
360
371
  p.snippet ? h('div', { class: 'cm-forum-item-snippet' }, p.snippet) : null,
361
372
  h('div', { class: 'cm-forum-item-meta' },
@@ -3,7 +3,7 @@
3
3
  // ProjectView, Form. Pure factories.
4
4
 
5
5
  import * as webjsx from '../../vendor/webjsx/index.js';
6
- import { Btn, Heading, Lede, Dot } from './shell.js';
6
+ import { Btn, Heading, Lede, Dot, Icon } from './shell.js';
7
7
  const h = webjsx.createElement;
8
8
 
9
9
  export function Panel({ title, count, right, style = '', children, kind }) {
@@ -351,14 +351,14 @@ export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'l
351
351
  }
352
352
 
353
353
  export function Alert({ kind = 'info', children, onDismiss, title, key } = {}) {
354
- const icons = { info: '', success: '', warn: '', error: '' };
354
+ const icons = { info: 'info', success: 'check', warn: 'warn', error: 'x' };
355
355
  const cls = 'ds-alert ds-alert-' + kind;
356
356
  return h('div', { key, class: cls, role: 'alert' },
357
- h('span', { key: 'icon', class: 'ds-alert-icon' }, icons[kind]),
357
+ h('span', { key: 'icon', class: 'ds-alert-icon' }, Icon(icons[kind] || 'info')),
358
358
  h('div', { key: 'content', class: 'ds-alert-content' },
359
359
  title ? h('div', { key: 'title', class: 'ds-alert-title' }, title) : null,
360
360
  h('div', { key: 'msg', class: 'ds-alert-message' }, ...(Array.isArray(children) ? children : [children]))
361
361
  ),
362
- onDismiss ? h('button', { key: 'dismiss', class: 'ds-alert-dismiss', onclick: onDismiss }, '') : null
362
+ onDismiss ? h('button', { key: 'dismiss', class: 'ds-alert-dismiss', 'aria-label': 'dismiss', onclick: onDismiss }, Icon('x')) : null
363
363
  );
364
364
  }
@@ -5,6 +5,7 @@
5
5
  // via the kit's data-theme attribute on the .ds-247420 scope root.
6
6
 
7
7
  import * as webjsx from '../../vendor/webjsx/index.js';
8
+ import { Icon } from './shell.js';
8
9
  const h = webjsx.createElement;
9
10
 
10
11
  function kids(c) { return c == null ? [] : (Array.isArray(c) ? c : [c]); }
@@ -122,7 +123,7 @@ export function TreeItem({ label, glyph, tag, depth = 0, selected = false, expan
122
123
  class: 'ds-ep-tree-twist' + (expanded ? ' open' : ''),
123
124
  'aria-hidden': 'true',
124
125
  onclick: (e) => { e.stopPropagation(); if (hasKids && onToggle) onToggle(); }
125
- }, hasKids ? '' : ''),
126
+ }, hasKids ? Icon('chevron-right') : ''),
126
127
  glyph != null ? h('span', { class: 'ds-ep-tree-glyph', 'aria-hidden': 'true' }, glyph) : null,
127
128
  h('span', { class: 'ds-ep-tree-label' }, label),
128
129
  tag != null ? h('span', { class: 'ds-ep-tree-tag' }, tag) : null
@@ -1,7 +1,7 @@
1
1
  // File modals — matches upstream signatures + class names.
2
2
 
3
3
  import * as webjsx from '../../vendor/webjsx/index.js';
4
- import { Btn } from './shell.js';
4
+ import { Btn, Icon } from './shell.js';
5
5
  import { fileGlyph, fmtFileSize } from './files.js';
6
6
  const h = webjsx.createElement;
7
7
 
@@ -154,8 +154,8 @@ export function FileViewer({ file, body, onClose, onAction } = {}) {
154
154
  h('span', { class: 'ds-preview-name' }, file.name || ''),
155
155
  h('span', { class: 'ds-preview-meta' }, meta),
156
156
  h('span', { class: 'ds-preview-actions' },
157
- onAction ? h('button', { class: 'ds-file-act', title: 'download', onclick: () => onAction('download') }, '') : null,
158
- h('button', { class: 'ds-file-act', title: 'close', onclick: onClose }, '')
157
+ onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
158
+ h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x'))
159
159
  )
160
160
  ),
161
161
  h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
@@ -1,13 +1,13 @@
1
1
  // File primitives — matches upstream signatures.
2
2
 
3
3
  import * as webjsx from '../../vendor/webjsx/index.js';
4
- import { Btn } from './shell.js';
4
+ import { Btn, Icon } from './shell.js';
5
5
  const h = webjsx.createElement;
6
6
 
7
7
  const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
8
- const TYPE_GLYPH = {
9
- dir: '', image: '', video: '', audio: '', code: '',
10
- text: '§', archive: '', document: '', symlink: '', other: ''
8
+ const TYPE_ICON = {
9
+ dir: 'file', image: 'file', video: 'file-video', audio: 'file-audio', code: 'file-code',
10
+ text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'file', other: 'file'
11
11
  };
12
12
 
13
13
  const TYPE_LABELS = {
@@ -24,7 +24,7 @@ const TYPE_LABELS = {
24
24
  };
25
25
 
26
26
  export function fileGlyph(type) {
27
- return TYPE_GLYPH[type] || TYPE_GLYPH.other;
27
+ return TYPE_ICON[type] || TYPE_ICON.other;
28
28
  }
29
29
 
30
30
  export function fmtFileSize(bytes) {
@@ -36,7 +36,7 @@ export function fmtFileSize(bytes) {
36
36
  }
37
37
 
38
38
  export function FileIcon({ type = 'other' } = {}) {
39
- return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, fileGlyph(type));
39
+ return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
40
40
  }
41
41
 
42
42
  export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key } = {}) {
@@ -64,9 +64,9 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
64
64
  h('span', { class: 'title' }, name),
65
65
  h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
66
66
  onAction ? h('span', { class: 'ds-file-actions', onclick: (e) => e.stopPropagation(), role: 'group', 'aria-label': `actions for ${name}` },
67
- h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, ''),
68
- h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, ''),
69
- h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, '')
67
+ h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, Icon('arrow-down')),
68
+ h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
69
+ h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
70
70
  ) : null
71
71
  );
72
72
  }
@@ -141,7 +141,7 @@ export function UploadProgress({ items = [] } = {}) {
141
141
  );
142
142
  }
143
143
 
144
- export function EmptyState({ text = 'nothing here', glyph = '' } = {}) {
144
+ export function EmptyState({ text = 'nothing here', glyph = Icon('circle') } = {}) {
145
145
  return h('div', { class: 'ds-file-empty' },
146
146
  h('span', { class: 'ds-file-empty-glyph' }, glyph),
147
147
  h('span', { class: 'ds-file-empty-text' }, text)
@@ -151,7 +151,7 @@ export function EmptyState({ text = 'nothing here', glyph = '◌' } = {}) {
151
151
  export function BreadcrumbPath({ segments = [], onNav, root = 'root' } = {}) {
152
152
  const parts = [h('button', { key: 'root', class: 'ds-crumb-seg', onclick: () => onNav && onNav(0) }, root)];
153
153
  segments.forEach((seg, i) => {
154
- parts.push(h('span', { key: 'sep' + i, class: 'ds-crumb-sep' }, ''));
154
+ parts.push(h('span', { key: 'sep' + i, class: 'ds-crumb-sep', 'aria-hidden': 'true' }, Icon('chevron-right', { size: 13 })));
155
155
  parts.push(h('button', {
156
156
  key: 'seg' + i,
157
157
  class: 'ds-crumb-seg' + (i === segments.length - 1 ? ' leaf' : ''),
@@ -5,6 +5,7 @@
5
5
  // a ref callback that uses the SDK's own applyDiff.
6
6
 
7
7
  import * as webjsx from '../../../vendor/webjsx/index.js';
8
+ import { Icon } from '../shell.js';
8
9
  const h = webjsx.createElement;
9
10
  const applyDiff = webjsx.applyDiff;
10
11
 
@@ -57,7 +58,7 @@ export function makePage(setup, { initial = {} } = {}) {
57
58
  try { body = render(); }
58
59
  catch (e) {
59
60
  body = h('div', { class: 'ds-alert ds-alert-error', role: 'alert' },
60
- h('span', { class: 'ds-alert-icon' }, ''),
61
+ h('span', { class: 'ds-alert-icon' }, Icon('x')),
61
62
  h('div', { class: 'ds-alert-content' },
62
63
  h('div', { class: 'ds-alert-title' }, 'page render error'),
63
64
  h('pre', { class: 'fd-pre' }, String(e && e.stack || e))));
@@ -70,7 +71,14 @@ export function makePage(setup, { initial = {} } = {}) {
70
71
  elRef = el;
71
72
  const r = setup(ctx);
72
73
  if (typeof r === 'function') render = r;
74
+ // Paint immediately, then again on the next microtask. Pages whose
75
+ // setup only seeds state synchronously (chat, batch) get a single
76
+ // ref-time paint; if that paint lands before the node is fully live
77
+ // in the document the diff can no-op, leaving an empty page-root.
78
+ // The deferred second paint guarantees content regardless of attach
79
+ // timing. Pages that also load() async are unaffected (idempotent).
73
80
  ctx.rerender();
81
+ Promise.resolve().then(() => ctx.rerender());
74
82
  };
75
83
  return h('div', { class: 'fd-page-root', ref });
76
84
  };
@@ -87,14 +95,14 @@ export function loadingState(label = 'loading…') {
87
95
  export function errorState(err, onRetry) {
88
96
  const msg = String(err && err.message || err);
89
97
  return h('div', { class: 'ds-alert ds-alert-error', role: 'alert' },
90
- h('span', { class: 'ds-alert-icon' }, ''),
98
+ h('span', { class: 'ds-alert-icon' }, Icon('x')),
91
99
  h('div', { class: 'ds-alert-content' },
92
100
  h('div', { class: 'ds-alert-title' }, 'failed to load'),
93
101
  h('div', { class: 'ds-alert-message' }, msg),
94
- onRetry ? h('button', { class: 'btn', onclick: onRetry, style: 'margin-top:8px' }, 'retry') : null));
102
+ onRetry ? h('button', { class: 'btn ds-alert-retry', onclick: onRetry }, 'retry') : null));
95
103
  }
96
104
 
97
- export function emptyState(text = 'nothing here yet', glyph = '') {
105
+ export function emptyState(text = 'nothing here yet', glyph = Icon('circle')) {
98
106
  return h('div', { class: 'fd-empty', role: 'status' },
99
107
  h('div', { class: 'fd-empty-glyph', 'aria-hidden': 'true' }, glyph),
100
108
  h('div', { class: 'dim' }, text));
@@ -8,7 +8,7 @@ import * as webjsx from '../../vendor/webjsx/index.js';
8
8
  import { makePage, api, loadingState, errorState, emptyState } from './freddie/runtime.js';
9
9
  import { getRecentPaths, saveRecentPath, skillLabel, renderChatMessages } from './freddie/helpers.js';
10
10
  import { Panel, Row, Table, Kpi, PageHeader, SearchInput, TextField, Select } from './content.js';
11
- import { Chip, Btn } from './shell.js';
11
+ import { Chip, Btn, Icon } from './shell.js';
12
12
  import { ChatMessage, ChatComposer } from './chat.js';
13
13
 
14
14
  const h = webjsx.createElement;
@@ -29,7 +29,7 @@ const noteAlert = (note) => note ? h('div', { class: 'ds-alert ds-alert-' + note
29
29
  h('span', { class: 'ds-alert-icon' }, '!'),
30
30
  h('div', { class: 'ds-alert-content' }, note.msg)) : null;
31
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' });
32
+ const refreshBtn = (onClick, busy) => Btn({ children: busy ? 'refreshing…' : [Icon('refresh'), ' refresh'], disabled: !!busy, onClick, 'aria-label': 'refresh' });
33
33
  // Non-blocking refresh-error banner: keep last-good content, surface the failure.
34
34
  const refreshError = (err) => err ? h('div', { class: 'ds-alert ds-alert-warn', role: 'status', 'aria-live': 'polite' },
35
35
  h('span', { class: 'ds-alert-icon' }, '!'),
@@ -104,7 +104,7 @@ export const chat = makePage((ctx) => {
104
104
  const reply = r.result || r.content || r.message || (r.messages && r.messages.at(-1)?.content) || JSON.stringify(r);
105
105
  ctx.state.messages.push({ role: 'assistant', text: String(reply), time: new Date().toLocaleTimeString() });
106
106
  } catch (e) {
107
- ctx.state.messages.push({ role: 'assistant', text: ' ' + String(e.message || e), time: new Date().toLocaleTimeString() });
107
+ ctx.state.messages.push({ role: 'assistant', text: 'Error: ' + String(e.message || e), time: new Date().toLocaleTimeString() });
108
108
  }
109
109
  ctx.set({ sending: false });
110
110
  }
@@ -116,7 +116,7 @@ export const chat = makePage((ctx) => {
116
116
  h('div', { class: 'chat-thread fd-chat-thread', role: 'log', 'aria-label': 'chat messages',
117
117
  ref: stickyScroll },
118
118
  s.messages.length ? s.messages.map((m, i) => ChatMessage({ ...m, key: i }))
119
- : emptyState('send a prompt to start', ''),
119
+ : emptyState('send a prompt to start', Icon('forum')),
120
120
  s.sending ? ChatMessage({ role: 'assistant', typing: true, key: '_typing' }) : null),
121
121
  ChatComposer({
122
122
  value: s.draft,
@@ -225,7 +225,7 @@ export const projects = makePage((ctx) => {
225
225
  noteAlert(s.note),
226
226
  section('projects',
227
227
  list.length ? list.map((p, i) => Row({
228
- key: i, code: p.name === activeName ? '' : '', title: p.name, sub: p.path || '',
228
+ key: i, code: h('span', { class: 'ds-dot ' + (p.name === activeName ? 'ds-dot-on' : 'ds-dot-off'), 'aria-hidden': 'true' }), title: p.name, sub: p.path || '',
229
229
  active: p.name === activeName,
230
230
  trailing: h('span', { class: 'fd-row-actions' },
231
231
  p.name !== activeName ? Btn({ children: 'activate', onClick: () => activate(p.name) }) : Chip({ tone: 'ok', children: 'active' }),
@@ -354,7 +354,7 @@ export const cron = makePage((ctx) => {
354
354
  PageHeader({ eyebrow: 'freddie', title: 'cron', lede: list.length + ' scheduled jobs' }),
355
355
  noteAlert(s.note),
356
356
  section('jobs', list.length ? list.map((j, i) => Row({
357
- key: i, code: j.enabled ? '' : '', title: j.cron, sub: (j.prompt || '').slice(0, 80),
357
+ key: i, code: j.enabled ? Icon('play') : Icon('pause'), title: j.cron, sub: (j.prompt || '').slice(0, 80),
358
358
  trailing: Btn({ danger: true, children: 'delete', onClick: () => del(j.id) }),
359
359
  })) : emptyState('no cron jobs')),
360
360
  section('new job',