anentrypoint-design 0.0.385 → 0.0.387

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. package/src/page-html.js +14 -291
@@ -1,448 +1,19 @@
1
1
  // Community surface — matches upstream signatures.
2
-
3
- import * as webjsx from '../../vendor/webjsx/index.js';
4
- import { Icon } from './shell.js';
5
- import { Avatar, avatarInitial } from './content.js';
6
- import { sanitizeHtml } from '../markdown.js';
7
- const h = webjsx.createElement;
8
-
9
- // Clamp a count to a compact badge string (matches the rail's 99+ convention),
10
- // so a runaway number never blows out a fixed-width badge or item row.
11
- const clampCount = (n) => { const v = Number(n) || 0; return v > 99 ? '99+' : String(v); };
12
-
13
- export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
14
- return h('div', {
15
- class: 'cm-server-icon' + (active ? ' active' : ''),
16
- onclick: onClick,
17
- role: 'button',
18
- 'aria-label': name,
19
- 'aria-pressed': active ? 'true' : 'false',
20
- tabindex: '0',
21
- 'data-id': id,
22
- onkeydown: (e) => {
23
- if (e.key === 'Enter' || e.key === ' ') {
24
- e.preventDefault();
25
- onClick && onClick(e);
26
- }
27
- }
28
- },
29
- h('span', { class: 'cm-server-pill' }),
30
- icon ? h('img', { src: icon, alt: name }) : Avatar({ name, shape: 'square', initialsCount: 2 }),
31
- badge ? h('span', { class: 'cm-server-badge' }, badge > 99 ? '99+' : String(badge)) : null
32
- );
33
- }
34
-
35
- export function ServerRail({ servers = [], activeId, onSelect, onAdd } = {}) {
36
- return h('div', { class: 'cm-server-rail', role: 'navigation', 'aria-label': 'servers' },
37
- h('a', { class: 'cm-server-back', href: '../', title: 'Back', 'aria-label': 'back' }, Icon('chevron-left')),
38
- h('div', { class: 'cm-server-sep', 'aria-hidden': 'true' }),
39
- ...servers.map(s => ServerIcon({ ...s, active: s.id === activeId, onClick: () => onSelect && onSelect(s.id) })),
40
- onAdd ? h('button', { class: 'cm-server-add', type: 'button', onclick: onAdd, title: 'Add server', 'aria-label': 'add server' }, '+') : null
41
- );
42
- }
43
-
44
- export function ChannelItem({ id, name, type = 'text', active, voiceActive, voiceConnecting, badge, draggable, actions = [], participants = [], onClick, onContext } = {}) {
45
- const ICON_FOR = { voice: 'speaker', forum: 'forum', threaded: 'thread', announcement: 'megaphone', page: 'page', thread: 'thread', text: 'hash' };
46
- const icon = Icon(ICON_FOR[type] || 'hash', { size: 15 });
47
- const handleActionClick = (a, e) => { e.stopPropagation(); a.onClick && a.onClick(id, e); };
48
- return h('div', { class: 'cm-channel-item-wrap', 'data-channel-wrap': id },
49
- h('div', {
50
- class: 'cm-channel-item' + (active ? ' active' : '') + (voiceActive ? ' voice-active' : '') + (voiceConnecting ? ' voice-connecting' : ''),
51
- 'data-id': id,
52
- 'data-type': type,
53
- draggable: draggable ? 'true' : null,
54
- onclick: onClick,
55
- oncontextmenu: (e) => { e.preventDefault(); onContext && onContext(id, e.clientX, e.clientY); },
56
- onkeydown: (e) => {
57
- if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
58
- e.preventDefault();
59
- const rect = e.currentTarget.getBoundingClientRect();
60
- onContext && onContext(id, rect.left, rect.top + rect.height);
61
- }
62
- if (draggable) {
63
- if (e.ctrlKey && e.key === 'ArrowUp') {
64
- e.preventDefault();
65
- window.dispatchEvent(new CustomEvent('reorder', { detail: { id, direction: 'up' } }));
66
- }
67
- if (e.ctrlKey && e.key === 'ArrowDown') {
68
- e.preventDefault();
69
- window.dispatchEvent(new CustomEvent('reorder', { detail: { id, direction: 'down' } }));
70
- }
71
- }
72
- },
73
- tabindex: '0',
74
- role: 'option'
75
- },
76
- h('span', { class: 'cm-ch-icon' + (voiceActive ? ' voice-active-badge' : ''), 'data-voice-active': voiceActive ? 'true' : null }, icon),
77
- voiceConnecting ? h('span', { class: 'cm-ch-spinner', title: 'Connecting…', 'aria-label': 'Connecting to voice channel…' }) : voiceActive ? h('span', { class: 'cm-ch-voice-badge', title: 'Voice active', 'aria-label': 'Voice channel active' }) : null,
78
- h('span', { class: 'cm-ch-name' }, name),
79
- badge ? h('span', { class: 'cm-ch-badge' }, badge > 99 ? '99+' : String(badge)) : null,
80
- actions.length ? h('div', { class: 'cm-ch-actions' },
81
- ...actions.map(a => h('button', {
82
- class: 'cm-ch-action-btn',
83
- title: a.title || '',
84
- 'data-action': a.id || '',
85
- onclick: (e) => handleActionClick(a, e)
86
- }, a.icon || a.label || 'more'))
87
- ) : null
88
- ),
89
- voiceActive && participants.length ? h('div', { class: 'cm-ch-voice-users' },
90
- ...participants.map(p => h('div', { class: 'cm-ch-voice-user' + (p.speaking ? ' speaking' : '') },
91
- h('div', { class: 'cm-ch-voice-user-avatar', style: p.color ? `--avatar-bg:${p.color}` : null }, avatarInitial(p.identity)),
92
- h('span', { class: 'cm-ch-voice-user-name' }, p.identity)
93
- ))
94
- ) : null
95
- );
96
- }
97
-
98
- export function ChannelCategory({ id, name, channels = [], collapsed, activeId, onToggle, onAddChannel, onChannelClick, onChannelContext, onContextMenu, extraButton, channelDraggable } = {}) {
99
- return h('div', { class: 'cm-channel-category', 'data-category': id },
100
- h('div', {
101
- class: 'cm-category-header' + (collapsed ? ' collapsed' : ''),
102
- onclick: () => onToggle && onToggle(id),
103
- oncontextmenu: onContextMenu ? (e) => { e.preventDefault(); onContextMenu(id, e.clientX, e.clientY); } : null
104
- },
105
- h('span', { class: 'cm-cat-arrow' }, Icon('chevron-down')),
106
- h('span', { class: 'cm-cat-name' }, name),
107
- extraButton ? h('button', { class: 'cm-cat-extra', onclick: (e) => { e.stopPropagation(); extraButton.onClick && extraButton.onClick(id, e); }, 'aria-label': extraButton.title || 'Category action' }, extraButton.icon || extraButton.label || '+') : null,
108
- onAddChannel ? h('button', { class: 'cm-cat-add', onclick: (e) => { e.stopPropagation(); onAddChannel(id); }, 'aria-label': 'Add channel to ' + name }, '+') : null
109
- ),
110
- collapsed ? null : h('div', { class: 'cm-cat-channels' },
111
- ...channels.map(c => ChannelItem({
112
- ...c,
113
- draggable: channelDraggable,
114
- active: c.id === activeId,
115
- onClick: () => onChannelClick && onChannelClick(c),
116
- onContext: onChannelContext
117
- }))
118
- )
119
- );
120
- }
121
-
122
- export function VoiceUser({ identity, speaking, color } = {}) {
123
- const initial = avatarInitial(identity);
124
- return h('div', { class: 'cm-voice-user' + (speaking ? ' speaking' : '') },
125
- h('div', { class: 'cm-voice-user-avatar', style: color ? `--avatar-bg:${color}` : null }, initial),
126
- h('span', { class: 'cm-voice-user-name' }, identity)
127
- );
128
- }
129
-
130
- export function UserPanel({ name, tag, color, muted, deafened, onMute, onDeafen, onSettings } = {}) {
131
- const initial = avatarInitial(name);
132
- const handleSettings = (e) => {
133
- e.preventDefault();
134
- if (onSettings) {
135
- // onSettings callback should open a drawer/modal with quick toggles
136
- onSettings({
137
- audioDevice: null, // controlled by consumer
138
- micOn: !muted,
139
- speakerOn: !deafened,
140
- });
141
- }
142
- };
143
- return h('div', { class: 'cm-user-panel' },
144
- h('div', { class: 'cm-user-avatar', style: color ? `--avatar-bg:${color}` : null },
145
- h('span', { class: 'cm-user-status-dot' }),
146
- initial
147
- ),
148
- h('div', { class: 'cm-user-info' },
149
- h('div', { class: 'cm-user-name' }, name || 'You'),
150
- tag ? h('div', { class: 'cm-user-tag' }, tag) : null
151
- ),
152
- h('div', { class: 'cm-user-controls' },
153
- 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')),
154
- 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')),
155
- h('button', { class: 'cm-user-btn', onclick: handleSettings, 'aria-label': 'Audio settings', title: 'Open audio settings' }, Icon('settings'))
156
- )
157
- );
158
- }
159
-
160
- // Skeleton rows for a cold channel-list load — reuses the kit-wide .ds-skel
161
- // shimmer primitive (sessions.js / files.js) rather than a bare spinner.
162
- function ChannelListSkeleton({ rows = 6 } = {}) {
163
- return h('div', { class: 'cm-channel-list cm-channel-skeleton', 'aria-hidden': 'true' },
164
- ...Array.from({ length: rows }, (_, i) => h('div', { key: 'csk' + i, class: 'cm-channel-item-skeleton' },
165
- h('span', { class: 'ds-skel ds-skel-icon' }), h('span', { class: 'ds-skel ds-skel-title' }))));
166
- }
167
-
168
- export function ChannelSidebar({ serverName, channels = [], categories = [], activeId, collapsedCats, onChannelClick, onCategoryToggle, onAddChannel, onChannelContext, userPanelProps, loading = false } = {}) {
169
- const collapsed = collapsedCats || new Set();
170
- const uncategorized = channels.filter(c => !c.categoryId || !categories.find(cat => cat.id === c.categoryId));
171
- const sorted = [...categories].sort((a, b) => (a.position || 0) - (b.position || 0));
172
- return h('div', { class: 'cm-channel-sidebar' },
173
- h('div', { class: 'cm-server-header' },
174
- h('span', { class: 'cm-server-header-name' }, serverName || 'Server'),
175
- ),
176
- loading ? ChannelListSkeleton() : h('div', { class: 'cm-channel-list' },
177
- (sorted.length === 0 && uncategorized.length === 0)
178
- ? h('div', { class: 'cm-channel-empty', role: 'status' },
179
- Icon('hash', { size: 20 }),
180
- h('span', { class: 'cm-channel-empty-text' }, 'no channels yet — add one to get this server started'))
181
- : null,
182
- ...sorted.map(cat => ChannelCategory({
183
- id: cat.id,
184
- name: cat.name,
185
- channels: channels.filter(c => c.categoryId === cat.id).sort((a, b) => (a.position || 0) - (b.position || 0)),
186
- collapsed: collapsed.has && collapsed.has(cat.id),
187
- activeId,
188
- onToggle: onCategoryToggle,
189
- onAddChannel,
190
- onChannelClick,
191
- onChannelContext
192
- })),
193
- uncategorized.length ? ChannelCategory({
194
- id: 'uncategorized',
195
- name: 'CHANNELS',
196
- channels: uncategorized,
197
- activeId,
198
- onChannelClick,
199
- onChannelContext
200
- }) : null
201
- ),
202
- userPanelProps ? UserPanel(userPanelProps) : null
203
- );
204
- }
205
-
206
- export function MemberItem({ identity, name, color, status = 'online' } = {}) {
207
- const initial = avatarInitial(name || identity);
208
- return h('div', { class: 'cm-member-item' },
209
- h('div', { class: 'cm-member-avatar', style: color ? `--avatar-bg:${color}` : null },
210
- h('span', { class: 'cm-member-status' + (status === 'online' ? ' online' : '') }),
211
- initial
212
- ),
213
- h('span', { class: 'cm-member-name' }, name || identity)
214
- );
215
- }
216
-
217
- // Skeleton rows for a cold member-list load, matching FileSkeleton/session
218
- // skeleton shape (icon + title placeholder), never a bare spinner.
219
- function MemberListSkeleton({ rows = 6 } = {}) {
220
- return h('div', { class: 'cm-member-list cm-member-skeleton open', 'aria-hidden': 'true' },
221
- ...Array.from({ length: rows }, (_, i) => h('div', { key: 'msk' + i, class: 'cm-member-item-skeleton' },
222
- h('span', { class: 'ds-skel ds-skel-icon' }), h('span', { class: 'ds-skel ds-skel-title' }))));
223
- }
224
-
225
- export function MemberList({ categories = [], open, loading = false } = {}) {
226
- if (loading) return MemberListSkeleton();
227
- const total = categories.reduce((n, cat) => n + (cat.members ? cat.members.length : 0), 0);
228
- return h('div', { class: 'cm-member-list' + (open ? ' open' : '') },
229
- total === 0
230
- ? h('div', { key: '_empty', class: 'cm-member-empty', role: 'status' },
231
- Icon('members', { size: 20 }),
232
- h('span', { class: 'cm-member-empty-text' }, 'no members in this channel yet'))
233
- : null,
234
- ...categories.flatMap(cat => [
235
- h('div', { class: 'cm-member-category', key: cat.label }, `${cat.label} — ${cat.members.length}`),
236
- ...cat.members.map((m, i) => MemberItem({ ...m, key: m.identity || i }))
237
- ])
238
- );
239
- }
240
-
241
- export function ChatHeader({ icon = '#', name, topic, toolbar = [] } = {}) {
242
- return h('div', { class: 'cm-chat-header' },
243
- h('span', { class: 'cm-chat-header-icon' }, icon),
244
- h('span', { class: 'cm-chat-header-name' }, name),
245
- topic ? h('span', { class: 'cm-chat-header-topic' }, topic) : null,
246
- h('div', { class: 'cm-chat-header-toolbar' }, ...toolbar)
247
- );
248
- }
249
-
250
- export function VoiceStrip({ channelName, status, muted, deafened, onMute, onDeafen, onLeave, open } = {}) {
251
- return h('div', { class: 'cm-voice-strip' + (open ? ' open' : ''), role: 'region', 'aria-label': 'voice controls' },
252
- h('div', { class: 'cm-vs-label' },
253
- h('span', { class: 'cm-vs-channel' }, Icon('speaker'), ' ' + (channelName || 'voice')),
254
- h('span', { class: 'cm-vs-status' }, status || 'connected')
255
- ),
256
- h('button', {
257
- class: 'cm-vs-btn', type: 'button', onclick: onMute,
258
- title: muted ? 'Unmute' : 'Mute',
259
- 'aria-label': muted ? 'unmute microphone' : 'mute microphone',
260
- 'aria-pressed': muted ? 'true' : 'false'
261
- }, Icon(muted ? 'mic-off' : 'mic')),
262
- h('button', {
263
- class: 'cm-vs-btn', type: 'button', onclick: onDeafen,
264
- title: deafened ? 'Undeafen' : 'Deafen',
265
- 'aria-label': deafened ? 'undeafen' : 'deafen',
266
- 'aria-pressed': deafened ? 'true' : 'false'
267
- }, Icon(deafened ? 'speaker-off' : 'speaker')),
268
- h('button', {
269
- class: 'cm-vs-btn danger', type: 'button', onclick: onLeave,
270
- title: 'Leave voice', 'aria-label': 'leave voice channel'
271
- }, Icon('x'))
272
- );
273
- }
274
-
275
- export function MobileHeader({ title, channelType, channelName, onMenu, onMembers } = {}) {
276
- const ICON_FOR = { voice: 'speaker', forum: 'forum', threaded: 'thread', announcement: 'megaphone', page: 'page', thread: 'thread', text: 'hash' };
277
- const titleNode = channelType
278
- ? [Icon(ICON_FOR[channelType] || 'hash', { size: 16 }), ' ' + (channelName || '')]
279
- : [title || ''];
280
- return h('div', { class: 'cm-mobile-header', role: 'banner' },
281
- h('button', {
282
- class: 'cm-mh-btn', type: 'button', onclick: onMenu,
283
- title: 'Menu', 'aria-label': 'open navigation menu'
284
- }, Icon('menu')),
285
- h('span', { class: 'cm-mh-title' }, ...titleNode),
286
- h('button', {
287
- class: 'cm-mh-btn', type: 'button', onclick: onMembers,
288
- title: 'Members', 'aria-label': 'show members'
289
- }, Icon('members'))
290
- );
291
- }
292
-
293
- export function ReplyBar({ quotedMessage, quotedAuthor, onCancel } = {}) {
294
- return h('div', { class: 'cm-reply-bar', role: 'status' },
295
- h('span', { class: 'cm-rb-label' }, 'Replying to ',
296
- h('strong', { class: 'cm-rb-author' }, quotedAuthor || 'unknown')
297
- ),
298
- h('span', { class: 'cm-rb-preview', title: quotedMessage || '' }, quotedMessage || ''),
299
- h('button', {
300
- class: 'cm-rb-cancel', type: 'button', onclick: onCancel,
301
- title: 'Cancel reply', 'aria-label': 'cancel reply'
302
- }, Icon('x'))
303
- );
304
- }
305
-
306
- export function Banner({ tone = 'info', message, visible, actionLabel, onAction, onClick } = {}) {
307
- if (!visible || !message) return null;
308
- return h('div', {
309
- class: 'cm-banner tone-' + tone + (onClick ? ' clickable' : ''),
310
- role: tone === 'error' || tone === 'warning' ? 'alert' : 'status',
311
- onclick: onClick || null
312
- },
313
- h('span', { class: 'cm-banner-msg' }, message),
314
- actionLabel ? h('button', {
315
- class: 'cm-banner-action', type: 'button',
316
- onclick: (e) => { e.stopPropagation(); onAction && onAction(e); }
317
- }, actionLabel) : null
318
- );
319
- }
320
-
321
- function fmtRelTime(ts) {
322
- const t = Number(ts) || 0;
323
- if (!t) return '';
324
- const ms = t > 1e12 ? t : t * 1000;
325
- const d = Math.max(0, Date.now() - ms);
326
- const m = Math.floor(d / 60000);
327
- if (m < 1) return 'now';
328
- if (m < 60) return m + 'm';
329
- const hr = Math.floor(m / 60);
330
- if (hr < 24) return hr + 'h';
331
- return Math.floor(hr / 24) + 'd';
332
- }
333
-
334
- // Skeleton rows for a cold thread/post-list load. Two lines per row (title +
335
- // meta) mirrors cm-tp-item/cm-forum-item's actual shape so the shimmer
336
- // doesn't jump on load. Reuses the kit-wide .ds-skel shimmer primitive.
337
- function ListSkeleton({ cls, rows = 5 } = {}) {
338
- return h('div', { class: cls + ' cm-list-skeleton', 'aria-hidden': 'true' },
339
- ...Array.from({ length: rows }, (_, i) => h('div', { key: 'lsk' + i, class: 'cm-list-item-skeleton' },
340
- h('span', { class: 'ds-skel ds-skel-title' }), h('span', { class: 'ds-skel ds-skel-meta' }))));
341
- }
342
-
343
- export function ThreadPanel({ threads = [], activeId = null, title = 'Threads', onSelect, onCreate, onClose, loading = false } = {}) {
344
- const list = Array.isArray(threads) ? threads : [];
345
- return h('div', { class: 'cm-thread-panel', role: 'complementary', 'aria-label': title },
346
- h('div', { class: 'cm-tp-head' },
347
- h('span', { class: 'cm-tp-title' }, title),
348
- h('div', { class: 'cm-tp-head-actions' },
349
- onCreate ? h('button', { type: 'button', class: 'cm-tp-new', 'aria-label': 'new thread', title: 'New thread', onclick: onCreate }, '+') : null,
350
- onClose ? h('button', { type: 'button', class: 'cm-tp-close', 'aria-label': 'close', title: 'Close', onclick: onClose }, Icon('x')) : null
351
- )
352
- ),
353
- loading ? ListSkeleton({ cls: 'cm-tp-list' }) : h('div', { class: 'cm-tp-list' },
354
- list.length
355
- ? list.map(t => h('button', {
356
- type: 'button', key: 'tp-' + t.id,
357
- class: 'cm-tp-item' + (t.id === activeId ? ' is-active' : '') + (t.unread ? ' is-unread' : ''),
358
- onclick: () => onSelect && onSelect(t.id)
359
- },
360
- t.unread ? h('span', { class: 'cm-tp-dot', 'aria-hidden': 'true' }) : null,
361
- h('span', { class: 'cm-tp-item-title' }, t.title || '(untitled)'),
362
- t.lastMessage ? h('span', { class: 'cm-tp-item-snippet' }, t.lastMessage) : null,
363
- h('span', { class: 'cm-tp-item-meta' },
364
- t.author ? h('span', { class: 'cm-tp-item-author' }, t.author) : null,
365
- t.time ? h('span', { class: 'cm-tp-item-time' }, fmtRelTime(t.time)) : null
366
- )
367
- ))
368
- : h('div', { class: 'cm-tp-empty', role: 'status' },
369
- Icon('thread', { size: 20 }),
370
- h('span', { class: 'cm-tp-empty-text' }, onCreate ? 'no threads yet — start one' : 'no threads yet'))
371
- )
372
- );
373
- }
374
-
375
- export function ForumView({ posts = [], onSearch, onSort, onSelect, onNewPost, loading = false } = {}) {
376
- const list = Array.isArray(posts) ? posts : [];
377
- return h('div', { class: 'cm-forum', role: 'region', 'aria-label': 'forum' },
378
- h('div', { class: 'cm-forum-toolbar' },
379
- h('input', {
380
- type: 'search', class: 'cm-forum-search', placeholder: 'Search posts…',
381
- 'aria-label': 'search posts',
382
- oninput: onSearch ? (e) => onSearch(e.target.value) : null
383
- }),
384
- h('select', {
385
- class: 'cm-forum-sort', 'aria-label': 'sort posts',
386
- onchange: onSort ? (e) => onSort(e.target.value) : null
387
- },
388
- h('option', { value: 'recent' }, 'Recent'),
389
- h('option', { value: 'replies' }, 'Most replies'),
390
- h('option', { value: 'oldest' }, 'Oldest')
391
- ),
392
- onNewPost ? h('button', { type: 'button', class: 'cm-forum-new', onclick: onNewPost }, 'New post') : null
393
- ),
394
- loading ? ListSkeleton({ cls: 'cm-forum-list' }) : h('div', { class: 'cm-forum-list' },
395
- list.length
396
- ? list.map(p => h('button', {
397
- type: 'button', key: 'fp-' + p.id, class: 'cm-forum-item',
398
- onclick: () => onSelect && onSelect(p.id)
399
- },
400
- h('div', { class: 'cm-forum-item-head' },
401
- h('span', { class: 'cm-forum-item-title' }, p.title || '(untitled)'),
402
- h('span', { class: 'cm-forum-item-replies' }, clampCount(p.replyCount), Icon('chevron-right', { size: 13 }))
403
- ),
404
- p.snippet ? h('div', { class: 'cm-forum-item-snippet' }, p.snippet) : null,
405
- h('div', { class: 'cm-forum-item-meta' },
406
- p.author ? h('span', { class: 'cm-forum-item-author' }, p.author) : null,
407
- p.time ? h('span', { class: 'cm-forum-item-time' }, fmtRelTime(p.time)) : null,
408
- Array.isArray(p.tags) && p.tags.length
409
- ? h('span', { class: 'cm-forum-item-tags' }, ...p.tags.map((tag, i) =>
410
- h('span', { class: 'cm-forum-tag', key: 'tg-' + i }, tag)))
411
- : null
412
- )
413
- ))
414
- : h('div', { class: 'cm-forum-empty', role: 'status' },
415
- Icon('forum', { size: 20 }),
416
- h('span', { class: 'cm-forum-empty-text' }, onNewPost ? 'no posts yet — start the discussion' : 'no posts yet'))
417
- )
418
- );
419
- }
420
-
421
- export function PageView({ title = '', html = '', isAdmin = false, onEdit } = {}) {
422
- return h('div', { class: 'cm-page', role: 'document' },
423
- h('div', { class: 'cm-page-head' },
424
- h('h1', { class: 'cm-page-title' }, title || ''),
425
- isAdmin && onEdit ? h('button', { type: 'button', class: 'cm-page-edit', onclick: onEdit }, 'Edit') : null
426
- ),
427
- h('div', {
428
- class: 'cm-page-body',
429
- // Page bodies are host/user-authored HTML, so they pass through the
430
- // DOMPurify gate before innerHTML — never injected raw (stored-XSS gate).
431
- ref: (el) => {
432
- if (!el) return;
433
- if (!html) { el.innerHTML = '<p class="cm-page-empty">This page is empty.</p>'; return; }
434
- sanitizeHtml(html).then((clean) => { el.innerHTML = clean; });
435
- }
436
- })
437
- );
438
- }
439
-
440
- export function CommunityShell({ serverRailProps, sidebarProps, children, memberListProps, voiceStripProps } = {}) {
441
- return h('div', { class: 'cm-shell' },
442
- serverRailProps ? ServerRail(serverRailProps) : null,
443
- sidebarProps ? ChannelSidebar(sidebarProps) : null,
444
- h('div', { class: 'cm-main' }, ...(Array.isArray(children) ? children : [children])),
445
- memberListProps ? MemberList(memberListProps) : null,
446
- voiceStripProps ? VoiceStrip(voiceStripProps) : null
447
- );
448
- }
2
+ //
3
+ // This module is a barrel: every component lives in a single-responsibility
4
+ // submodule under ./community/, and the public export surface here is unchanged
5
+ // no consumer import needs to move.
6
+
7
+ import { ServerIcon, ServerRail, ChannelItem, ChannelCategory, ChannelSidebar } from './community/navigation.js';
8
+ import { VoiceUser, UserPanel, MemberItem, MemberList, VoiceStrip } from './community/presence.js';
9
+ import { ChatHeader, MobileHeader, ReplyBar, Banner } from './community/chrome.js';
10
+ import { ThreadPanel, ForumView, PageView } from './community/views.js';
11
+ import { CommunityShell } from './community/shell.js';
12
+
13
+ export {
14
+ ServerIcon, ServerRail, ChannelItem, ChannelCategory, ChannelSidebar,
15
+ VoiceUser, UserPanel, MemberItem, MemberList, VoiceStrip,
16
+ ChatHeader, MobileHeader, ReplyBar, Banner,
17
+ ThreadPanel, ForumView, PageView,
18
+ CommunityShell,
19
+ };
@@ -0,0 +1,140 @@
1
+ // Surrounding file-browser chrome: the bulk-action strip, the drop zone, the
2
+ // per-file upload progress list, the empty state, the breadcrumb path, the
3
+ // toolbar band, and the multi-root segmented picker.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Btn, Icon } from '../shell.js';
7
+ const h = webjsx.createElement;
8
+
9
+ // BulkBar — the act-on-selection strip shown while a multi-select is active.
10
+ // Host renders it above the grid; `actions` are [{ label, onClick, danger,
11
+ // disabled }]; `busy` disables everything while a bulk operation is in flight.
12
+ export function BulkBar({ count = 0, noun = 'file', nounPlural, actions = [], onClear, busy = false } = {}) {
13
+ if (!count) return null;
14
+ // 'entry' pluralizes to 'entries', not 'entrys' - handle the -y noun class
15
+ // unless the host passes an explicit plural.
16
+ const plural = nounPlural || (/[^aeiou]y$/.test(noun) ? noun.slice(0, -1) + 'ies' : noun + 's');
17
+ const kids = [
18
+ h('span', { key: 'count', class: 'ds-bulkbar-count', role: 'status', 'aria-live': 'polite' },
19
+ count + ' ' + (count === 1 ? noun : plural) + ' selected'),
20
+ ...actions.map((a, i) => Btn({
21
+ key: 'bba' + i, danger: !!a.danger, disabled: busy || a.disabled,
22
+ onClick: a.onClick, children: a.label,
23
+ })),
24
+ onClear ? Btn({ key: 'bbclear', disabled: busy, onClick: onClear, children: 'clear selection' }) : null,
25
+ ].filter(Boolean);
26
+ return h('div', { class: 'ds-bulkbar', role: 'toolbar', 'aria-label': 'bulk file actions', 'aria-busy': busy ? 'true' : null }, ...kids);
27
+ }
28
+
29
+ export function FileToolbar({ left = [], right = [] } = {}) {
30
+ return h('div', { class: 'ds-file-toolbar' },
31
+ h('div', { class: 'ds-file-toolbar-left' }, ...left),
32
+ h('div', { class: 'ds-file-toolbar-right' }, ...right)
33
+ );
34
+ }
35
+
36
+ // RootsPicker — a segmented control for choosing among multiple allowed FS roots
37
+ // (so the app stops borrowing the history-tab .pill markup). Each root is
38
+ // { id, label }; `selected` is the active id. role=tablist for AT navigation.
39
+ export function RootsPicker({ roots = [], selected, onSelect, label = 'roots' } = {}) {
40
+ if (!roots.length) return null;
41
+ return h('div', { class: 'ds-roots-picker', role: 'tablist', 'aria-label': label },
42
+ ...roots.map((r) => h('button', {
43
+ key: 'root-' + (r.id != null ? r.id : r.label),
44
+ type: 'button', role: 'tab',
45
+ class: 'ds-roots-tab' + ((r.id != null ? r.id : r.label) === selected ? ' active' : ''),
46
+ 'aria-selected': (r.id != null ? r.id : r.label) === selected ? 'true' : 'false',
47
+ onclick: () => onSelect && onSelect(r.id != null ? r.id : r.label),
48
+ }, r.label || r.id)));
49
+ }
50
+
51
+ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave, label = 'drop files here', onPick } = {}) {
52
+ // With children the zone is a passive WRAPPER: content renders normally and
53
+ // the dashed affordance appears only while a drag is over it (real file
54
+ // managers never burn a permanent band on a maybe-drop). Without children
55
+ // it keeps the explicit picker-block look.
56
+ const kids = Array.isArray(children) ? children : children ? [children] : [];
57
+ return h('div', {
58
+ class: 'ds-dropzone' + (kids.length ? ' ds-dropzone--wrap' : '') + (dragover ? ' dragover' : ''),
59
+ ondragover: (e) => { e.preventDefault(); onDragOver && onDragOver(e); },
60
+ ondragleave: (e) => { if (!e.currentTarget.contains(e.relatedTarget)) { onDragLeave && onDragLeave(e); } },
61
+ ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
62
+ },
63
+ h('div', { class: 'ds-dropzone-inner' },
64
+ h('span', { class: 'ds-dropzone-glyph', role: 'img', 'aria-label': 'upload' }, Icon('arrow-up')),
65
+ h('span', { class: 'ds-dropzone-label' }, label),
66
+ onPick ? Btn({ onClick: onPick, children: 'pick files' }) : null
67
+ ),
68
+ ...kids
69
+ );
70
+ }
71
+
72
+ // UploadProgress — per-file upload rows. Error rows are recoverable, not dead
73
+ // ends: each item may carry `actions` ([{ label, onClick }], e.g. 'replace' on
74
+ // a 409 collision) and the host may wire `onDismiss(item, index)` so error rows
75
+ // can be cleared without waiting for the next successful batch.
76
+ export function UploadProgress({ items = [], onDismiss } = {}) {
77
+ if (!items.length) return null;
78
+ return h('div', { class: 'ds-upload-progress' },
79
+ ...items.map((it, i) => {
80
+ const indeterminate = !it.error && !it.done && !it.pct && it.indeterminate;
81
+ const status = it.error ? 'error' : (it.done ? 'complete' : (indeterminate ? 'uploading' : `uploading ${it.pct || 0}%`));
82
+ const rowActions = [
83
+ ...((it.actions || []).map((a, ai) => h('button', {
84
+ key: 'ua' + ai, type: 'button', class: 'ds-upload-act',
85
+ 'aria-label': `${a.label} ${it.name}`,
86
+ onclick: () => a.onClick && a.onClick(it, i),
87
+ }, a.label))),
88
+ (it.error && onDismiss) ? h('button', {
89
+ key: 'ud', type: 'button', class: 'ds-upload-act',
90
+ 'aria-label': `dismiss ${it.name}`,
91
+ onclick: () => onDismiss(it, i),
92
+ }, 'dismiss') : null,
93
+ ].filter(Boolean);
94
+ return h('div', {
95
+ key: it.name + i,
96
+ class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
97
+ role: 'status',
98
+ 'aria-label': `${it.name}: ${status}`,
99
+ 'aria-live': 'polite'
100
+ },
101
+ h('span', { class: 'ds-upload-name' }, it.name),
102
+ h('span', { class: 'ds-upload-bar' + (indeterminate ? ' indeterminate' : '') },
103
+ h('span', { class: 'ds-upload-fill', 'data-pct': String(Math.max(0, Math.min(100, it.pct || 0))), 'aria-hidden': 'true' })
104
+ ),
105
+ h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (indeterminate ? '...' : (it.pct || 0) + '%')))),
106
+ rowActions.length ? h('span', { class: 'ds-upload-actions', role: 'group', 'aria-label': `actions for ${it.name}` }, ...rowActions) : null
107
+ );
108
+ })
109
+ );
110
+ }
111
+
112
+ export function EmptyState({ text = 'nothing here', glyph = Icon('circle'), action } = {}) {
113
+ // action: { onClick, label } - an optional CTA (e.g. 'go up' / 'upload a
114
+ // file'), mirroring the SessionDashboard emptyAction contract so an empty
115
+ // directory is not a dead end. Children are built as an array + filtered so
116
+ // the keyed Btn never sits beside an unkeyed span (webjsx applyDiff 'key'
117
+ // crash on mixed keyed/unkeyed siblings).
118
+ return h('div', { class: 'ds-file-empty', role: 'status' },
119
+ ...[
120
+ h('span', { key: 'glyph', class: 'ds-file-empty-glyph', 'aria-hidden': 'true' }, glyph),
121
+ h('span', { key: 'text', class: 'ds-file-empty-text' }, text),
122
+ (action && action.onClick)
123
+ ? Btn({ key: 'ea', onClick: action.onClick, children: action.label || 'go up' })
124
+ : null,
125
+ ].filter(Boolean)
126
+ );
127
+ }
128
+
129
+ export function BreadcrumbPath({ segments = [], onNav, root = 'root' } = {}) {
130
+ const parts = [h('button', { key: 'root', class: 'ds-crumb-seg', onclick: () => onNav && onNav(0) }, root)];
131
+ segments.forEach((seg, i) => {
132
+ parts.push(h('span', { key: 'sep' + i, class: 'ds-crumb-sep', 'aria-hidden': 'true' }, Icon('chevron-right', { size: 13 })));
133
+ parts.push(h('button', {
134
+ key: 'seg' + i,
135
+ class: 'ds-crumb-seg' + (i === segments.length - 1 ? ' leaf' : ''),
136
+ onclick: () => onNav && onNav(i + 1)
137
+ }, seg));
138
+ });
139
+ return h('div', { class: 'ds-crumb-path' }, ...parts);
140
+ }