anentrypoint-design 0.0.206 → 0.0.208

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.206",
3
+ "version": "0.0.208",
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",
@@ -252,7 +252,11 @@ export function AgentChat(props = {}) {
252
252
  return ChatMessage({
253
253
  key: m.id || String(i),
254
254
  who: isAssistant ? 'them' : 'you',
255
- aicat: isAssistant,
255
+ // Claude-Code-web layout: flat full-width turns (no avatar disc, no colored
256
+ // bubble), distinguished by a role label + a faint assistant background.
257
+ // aicat is left OFF so the mascot tint never reaches the agent surface.
258
+ flat: true,
259
+ aicat: false,
256
260
  // A stable per-agent product mark (host passes a small line-SVG via
257
261
  // `avatar`) instead of a per-agent letter initial that shifts identity.
258
262
  avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
@@ -128,6 +128,18 @@ export function injectCodeCopy(container) {
128
128
  shell.className = 'chat-code-block';
129
129
  pre.parentNode.insertBefore(shell, pre);
130
130
  shell.appendChild(pre);
131
+ // Surface the fenced language as a small header tab (claude.ai/code
132
+ // shows the language on every block, not just the structured CodeNode).
133
+ // The highlighter sets language-xx / lang-xx on the inner <code>.
134
+ const codeEl = pre.querySelector('code');
135
+ const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
136
+ if (langCls && langCls[1]) {
137
+ const lang = document.createElement('span');
138
+ lang.className = 'chat-code-lang';
139
+ lang.setAttribute('aria-hidden', 'true');
140
+ lang.textContent = langCls[1];
141
+ shell.appendChild(lang);
142
+ }
131
143
  const btn = document.createElement('button');
132
144
  btn.type = 'button';
133
145
  btn.className = 'chat-code-copy';
@@ -289,7 +301,7 @@ function renderPart(p, key) {
289
301
  return node;
290
302
  }
291
303
 
292
- export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped }) {
304
+ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped, flat }) {
293
305
  _stats.messages += 1;
294
306
  // Support legacy 'who' prop, prefer 'role' with mapping:
295
307
  // 'user' -> 'you' (right-aligned, accent bubble)
@@ -304,7 +316,11 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
304
316
  : role)
305
317
  : who;
306
318
  const isCentered = resolvedWho === 'system' || resolvedWho === 'tool' || resolvedWho === 'thinking';
307
- const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '');
319
+ // Flat layout (Claude-Code-web): full-width, avatar-less turns with a role
320
+ // label above the content and a faint assistant background, instead of the
321
+ // messenger avatar-disc + colored-bubble layout (kept for the chat demo).
322
+ const isFlat = flat && !isCentered;
323
+ const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '') + (isFlat ? ' chat-msg-flat' : '');
308
324
  const fallbackAvatar = avatar != null
309
325
  ? avatar
310
326
  : (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?'));
@@ -355,11 +371,18 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
355
371
  }, a.icon ? Icon(a.icon, { size: 14 }) : null,
356
372
  a.label ? h('span', { class: 'chat-msg-action-label' }, a.label) : null)))
357
373
  : null;
358
- const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, actionRow, meta);
374
+ // Flat layout leads the turn with a small role label (You / agent name)
375
+ // above the content, the way claude.ai/code titles each turn.
376
+ const roleLabel = isFlat
377
+ ? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? 'You' : (name || 'Assistant'))
378
+ : null;
379
+ const stack = h('div', { class: 'chat-stack' }, roleLabel, ...bodyNodes, reactionRow, actionRow, meta);
359
380
  // Centered roles (system/tool/thinking) skip the avatar column entirely so
360
381
  // the bubble owns the full row — the chrome reads as out-of-band signal,
361
382
  // not a participant turn.
362
383
  if (isCentered) return h('div', { key, class: cls }, stack);
384
+ // Flat turns drop the avatar column entirely (full-width content).
385
+ if (isFlat) return h('div', { key, class: cls }, stack);
363
386
  return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
364
387
  }
365
388
 
@@ -3,6 +3,7 @@
3
3
  import * as webjsx from '../../vendor/webjsx/index.js';
4
4
  import { Btn, Icon } from './shell.js';
5
5
  import { fileGlyph, fmtFileSize } from './files.js';
6
+ import { highlightAllUnder } from '../highlight.js';
6
7
  const h = webjsx.createElement;
7
8
 
8
9
  // Monotonic id source for aria-labelledby wiring between a modal and its head.
@@ -205,7 +206,29 @@ export function PromptDialog({ title = 'name', value = '', placeholder = '', con
205
206
  }
206
207
 
207
208
  export function FilePreviewMedia({ src, type = 'other', name } = {}) {
208
- if (type === 'image') return h('img', { class: 'ds-preview-media', src, alt: name || '' });
209
+ if (type === 'image') {
210
+ // Fit-to-pane (default) vs actual-size (1:1) toggle + a checkerboard so
211
+ // transparency reads. The toggle flips a class on the img in-place and
212
+ // reports the natural pixel dimensions into its own caption on load.
213
+ const onToggle = (e) => {
214
+ const wrap = e.currentTarget.closest('.ds-preview-media-wrap');
215
+ const img = wrap && wrap.querySelector('.ds-preview-media');
216
+ if (!img) return;
217
+ const actual = img.classList.toggle('is-actual');
218
+ e.currentTarget.textContent = actual ? 'fit to pane' : 'actual size';
219
+ };
220
+ const onLoad = (e) => {
221
+ const img = e.currentTarget;
222
+ const cap = img.closest('.ds-preview-media-wrap');
223
+ const dim = cap && cap.querySelector('.ds-preview-media-dim');
224
+ if (dim && img.naturalWidth) dim.textContent = img.naturalWidth + ' x ' + img.naturalHeight + ' px';
225
+ };
226
+ return h('div', { class: 'ds-preview-media-wrap' },
227
+ h('img', { class: 'ds-preview-media ds-preview-media-alpha', src, alt: name || '', onload: onLoad }),
228
+ h('div', { class: 'ds-preview-media-controls' },
229
+ h('span', { class: 'ds-preview-media-dim', 'aria-live': 'polite' }, ''),
230
+ h('button', { type: 'button', class: 'chat-code-copy', onclick: onToggle }, 'actual size')));
231
+ }
209
232
  if (type === 'video') return h('video', { class: 'ds-preview-media', src, controls: true });
210
233
  if (type === 'audio') return h('audio', { class: 'ds-preview-audio', src, controls: true });
211
234
  return h('div', { class: 'ds-preview-fallback' },
@@ -230,11 +253,30 @@ export function FilePreviewCode({ content = '', lang, filename } = {}) {
230
253
  filename ? h('span', { class: 'name' }, filename) : null,
231
254
  h('span', { class: 'spread' }),
232
255
  h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
233
- h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') },
234
- h('code', { class: lang ? 'language-' + lang : '' }, content))
256
+ codeBody({ content, lang })
235
257
  );
236
258
  }
237
259
 
260
+ // The code body: a non-selectable line-number gutter + the highlighted code.
261
+ // A ref triggers Prism over the <code> after mount (the bundle only auto-runs
262
+ // Prism in the chat path), so the file preview is token-colored like Claude
263
+ // Code's file pane. lineNumbers defaults on for code, off for plaintext.
264
+ function codeBody({ content = '', lang } = {}) {
265
+ const wantGutter = !!lang;
266
+ const lineCount = content ? content.split('\n').length : 1;
267
+ const gutter = wantGutter
268
+ ? h('div', { class: 'ds-preview-gutter', 'aria-hidden': 'true' },
269
+ Array.from({ length: lineCount }, (_, i) => String(i + 1)).join('\n'))
270
+ : null;
271
+ const highlightRef = (el) => {
272
+ if (!el) return;
273
+ try { highlightAllUnder(el); } catch {}
274
+ };
275
+ return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : ''), ref: highlightRef },
276
+ gutter,
277
+ h('code', { class: lang ? 'language-' + lang : '' }, content));
278
+ }
279
+
238
280
  export function FilePreviewText({ content = '', truncated } = {}) {
239
281
  return h('pre', { class: 'ds-preview-text' },
240
282
  h('code', {}, content),
@@ -10,8 +10,8 @@ const FILE_GRID_MIN_COL = '240px';
10
10
 
11
11
  const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
12
12
  const TYPE_ICON = {
13
- dir: 'file', image: 'file', video: 'file-video', audio: 'file-audio', code: 'file-code',
14
- text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'file', other: 'file'
13
+ dir: 'folder', image: 'file-image', video: 'file-video', audio: 'file-audio', code: 'file-code',
14
+ text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'link', other: 'file'
15
15
  };
16
16
 
17
17
  const TYPE_LABELS = {
@@ -51,7 +51,7 @@ export function FileIcon({ type = 'other' } = {}) {
51
51
  const FILE_ROW_ACTIONS = ['download', 'rename', 'delete'];
52
52
 
53
53
  export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
54
- actions = FILE_ROW_ACTIONS, busy = false } = {}) {
54
+ actions = FILE_ROW_ACTIONS, busy = false, selectable = false, marked = false, onMark } = {}) {
55
55
  // permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
56
56
  // be listed (the dir stat saw it) but not opened — show an ASCII tag and
57
57
  // disable the open button so the row reads honestly instead of silently
@@ -85,17 +85,27 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
85
85
  actions.indexOf('delete') !== -1
86
86
  ? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
87
87
  ].filter(Boolean) : [];
88
+ // Multi-select checkbox — a sibling control before the open button so the
89
+ // row stays valid HTML (no interactive nesting). A no-access entry cannot
90
+ // be marked (bulk mutations would fail on it anyway).
91
+ const checkCtl = selectable ? h('button', {
92
+ key: 'mark',
93
+ type: 'button',
94
+ class: 'ds-file-check' + (marked ? ' is-marked' : ''),
95
+ role: 'checkbox',
96
+ 'aria-checked': marked ? 'true' : 'false',
97
+ 'aria-label': (marked ? 'unselect ' : 'select ') + name,
98
+ disabled: (noAccess || busy) ? true : null,
99
+ onclick: onMark ? (e) => onMark({ range: !!e.shiftKey }) : null,
100
+ }, h('span', { 'aria-hidden': 'true' }, marked ? '[x]' : '[ ]')) : null;
88
101
  // A role=button row containing real <button> action controls is invalid
89
102
  // HTML (interactive nesting). Instead the row is a plain container and the
90
103
  // primary "open" affordance is itself a real <button> (native keyboard +
91
104
  // semantics); the per-file action buttons sit alongside it as siblings.
92
- return h('div', {
93
- key,
94
- class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
95
- 'data-file-type': type,
96
- 'aria-busy': busy ? 'true' : null,
97
- },
105
+ const rowKids = [
106
+ checkCtl,
98
107
  h('button', {
108
+ key: 'open',
99
109
  type: 'button',
100
110
  class: 'ds-file-open',
101
111
  onclick: canOpen ? onOpen : null,
@@ -108,10 +118,17 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
108
118
  h('span', { class: 'title' }, name),
109
119
  h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
110
120
  ),
111
- actionBtns.length ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
121
+ actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
112
122
  ...actionBtns
113
- ) : null
114
- );
123
+ ) : null,
124
+ ].filter(Boolean);
125
+ return h('div', {
126
+ key,
127
+ class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
128
+ + (marked ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
129
+ 'data-file-type': type,
130
+ 'aria-busy': busy ? 'true' : null,
131
+ }, ...rowKids);
115
132
  }
116
133
 
117
134
  // FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
@@ -162,7 +179,9 @@ const FILE_GRID_CAP = 200;
162
179
 
163
180
  export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
164
181
  columns = 'auto', sort, filter, loading = false,
165
- shown, onShowMore, actions, busy } = {}) {
182
+ shown, onShowMore, actions, busy,
183
+ selectable = false, marked, onMark, onSelectAll, onClearSelection,
184
+ density = 'list', onDensity, thumbUrl } = {}) {
166
185
  if (loading) return FileSkeleton({});
167
186
  if (!files.length) return EmptyState({ text: emptyText });
168
187
  // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
@@ -170,8 +189,9 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
170
189
  const limit = shown != null ? shown : FILE_GRID_CAP;
171
190
  const capped = files.length > limit;
172
191
  const visible = capped ? files.slice(0, limit) : files;
192
+ const isThumb = density === 'thumb';
173
193
  const gridAttrs = {};
174
- if (columns !== 'auto' && columns > 0) {
194
+ if (!isThumb && columns !== 'auto' && columns > 0) {
175
195
  const col = Math.max(1, Math.min(4, Math.floor(columns)));
176
196
  gridAttrs['data-columns'] = String(col);
177
197
  gridAttrs.style = {
@@ -180,19 +200,59 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
180
200
  gap: 'var(--space-3)'
181
201
  };
182
202
  }
183
- // Keyboard: roving focus over the .ds-file-open buttons inside the grid.
203
+ // Multi-select bookkeeping. Entries are keyed by path (fallback name); a
204
+ // locked/EACCES entry is never selectable — bulk mutations would fail on it.
205
+ const entryKeyOf = (f) => f.path || f.name;
206
+ const isLockedEntry = (f) => f.locked || f.permissions === 'EACCES'
207
+ || (Array.isArray(f.permissions) && f.permissions.length === 0);
208
+ const selSet = marked instanceof Set ? marked : new Set(marked || []);
209
+ const selectableKeys = selectable ? visible.filter((f) => !isLockedEntry(f)).map(entryKeyOf) : [];
210
+ // Keyboard: roving focus over the open buttons inside the grid (rows and
211
+ // thumbnail cells share the pattern). Ctrl/Cmd+A selects all SHOWN rows.
184
212
  const onKeyDown = (e) => {
185
213
  const grid = e.currentTarget;
186
- const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled])'));
187
- if (!opens.length) return;
214
+ const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled]), .ds-file-cell-open:not([disabled])'));
188
215
  const cur = opens.indexOf(document.activeElement);
189
216
  if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
190
217
  else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
191
218
  else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
192
219
  else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
193
220
  else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
221
+ else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)
222
+ && selectable && onSelectAll && selectableKeys.length) {
223
+ e.preventDefault(); onSelectAll(selectableKeys);
224
+ }
194
225
  };
195
226
  const head = sort ? FileSortHeader(sort) : null;
227
+ // Tri-state select-all over the selectable SHOWN rows (the cap label below
228
+ // already tells the user more rows exist beyond the window).
229
+ const selOfVisible = selectableKeys.filter((k) => selSet.has(k)).length;
230
+ const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableKeys.length ? 'true' : 'mixed');
231
+ const selectAllCtl = (selectable && onSelectAll && selectableKeys.length)
232
+ ? h('button', { key: 'selall', type: 'button', class: 'ds-file-selectall', role: 'checkbox',
233
+ 'aria-checked': allState,
234
+ 'aria-label': allState === 'true' ? 'clear selection' : 'select all ' + selectableKeys.length + ' shown files',
235
+ onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableKeys) },
236
+ h('span', { 'aria-hidden': 'true' }, allState === 'true' ? '[x]' : allState === 'mixed' ? '[-]' : '[ ]'),
237
+ h('span', {}, 'all'))
238
+ : null;
239
+ // Density picker — list / compact / thumbnails. A radiogroup, not tabs:
240
+ // it switches presentation of the same content, not panels.
241
+ const densityCtl = onDensity
242
+ ? h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
243
+ ...DENSITIES.map(([k, label]) => h('button', {
244
+ key: 'd-' + k, type: 'button', role: 'radio',
245
+ class: 'ds-density-btn' + (density === k ? ' active' : ''),
246
+ 'aria-checked': density === k ? 'true' : 'false',
247
+ onclick: () => { if (density !== k) onDensity(k); },
248
+ }, label)))
249
+ : null;
250
+ const controlsKids = [selectAllCtl, head,
251
+ (selectAllCtl || head) && densityCtl ? h('span', { key: 'spread', class: 'spread' }) : null,
252
+ densityCtl].filter(Boolean);
253
+ const controls = controlsKids.length
254
+ ? h('div', { class: 'ds-file-controls' }, ...controlsKids)
255
+ : null;
196
256
  const filterBar = filter ? h('div', { class: 'ds-file-filter' },
197
257
  h('input', {
198
258
  class: 'ds-file-filter-input', type: 'search',
@@ -203,16 +263,32 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
203
263
  // role=group not listbox: the rows contain real <button> action controls, so
204
264
  // listbox/option semantics are invalid (an option can't host interactive
205
265
  // children). Keyboard nav still works via roving focus over the open buttons.
206
- const grid = h('div', { class: 'ds-file-grid', role: 'group', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
207
- ...visible.map((f, i) => FileRow({
208
- key: f.path || f.name + i,
209
- name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
210
- permissions: f.permissions, locked: f.locked,
211
- actions: actions != null ? actions : undefined,
212
- busy: busy != null ? !!busy : !!f.busy,
213
- onOpen: onOpen ? () => onOpen(f) : null,
214
- onAction: onAction ? (act) => onAction(act, f) : null
215
- }))
266
+ const grid = h('div', {
267
+ class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : ''),
268
+ role: 'group', 'aria-label': 'files', tabindex: '0',
269
+ // Always concrete (webjsx's attribute diff can leave a null-valued
270
+ // attribute unset when toggling away from the default).
271
+ 'data-density': density || 'list',
272
+ onkeydown: onKeyDown, ...gridAttrs },
273
+ ...visible.map((f, i) => isThumb
274
+ ? FileCell({
275
+ key: f.path || f.name + i, f,
276
+ selectable, marked: selSet.has(entryKeyOf(f)),
277
+ onMark: onMark ? (opts) => onMark(f, opts) : null,
278
+ onOpen,
279
+ thumb: (thumbUrl && f.type === 'image') ? thumbUrl(f) : null,
280
+ })
281
+ : FileRow({
282
+ key: f.path || f.name + i,
283
+ name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
284
+ permissions: f.permissions, locked: f.locked,
285
+ actions: actions != null ? actions : undefined,
286
+ busy: busy != null ? !!busy : !!f.busy,
287
+ selectable, marked: selSet.has(entryKeyOf(f)),
288
+ onMark: onMark ? (opts) => onMark(f, opts) : null,
289
+ onOpen: onOpen ? () => onOpen(f) : null,
290
+ onAction: onAction ? (act) => onAction(act, f) : null
291
+ }))
216
292
  );
217
293
  // A count + "show more" affordance so a capped large dir reads as "more
218
294
  // exist", not "this is everything". aria-live announces the shown/total.
@@ -224,11 +300,70 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
224
300
  onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
225
301
  'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
226
302
  : null;
227
- return (head || filterBar || more)
228
- ? h('div', { class: 'ds-file-listing' }, filterBar, head, grid, more)
303
+ return (controls || filterBar || more)
304
+ ? h('div', { class: 'ds-file-listing' }, filterBar, controls, grid, more)
229
305
  : grid;
230
306
  }
231
307
 
308
+ const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
309
+
310
+ // FileCell — the thumbnail-density tile. Image entries show a real (lazy)
311
+ // thumbnail through the host's confined thumbUrl; everything else keeps its
312
+ // type icon. Same open/mark semantics as FileRow, same no-nesting rule.
313
+ function FileCell({ key, f = {}, selectable = false, marked = false, onMark, onOpen, thumb } = {}) {
314
+ const noAccess = f.locked || f.permissions === 'EACCES'
315
+ || (Array.isArray(f.permissions) && f.permissions.length === 0);
316
+ const canOpen = onOpen && !noAccess;
317
+ const typeLabel = TYPE_LABELS[f.type] || 'file';
318
+ const kids = [
319
+ selectable ? h('button', {
320
+ key: 'mark', type: 'button',
321
+ class: 'ds-file-check ds-file-cell-check' + (marked ? ' is-marked' : ''),
322
+ role: 'checkbox', 'aria-checked': marked ? 'true' : 'false',
323
+ 'aria-label': (marked ? 'unselect ' : 'select ') + f.name,
324
+ disabled: noAccess ? true : null,
325
+ onclick: onMark ? (e) => onMark({ range: !!e.shiftKey }) : null,
326
+ }, h('span', { 'aria-hidden': 'true' }, marked ? '[x]' : '[ ]')) : null,
327
+ h('button', {
328
+ key: 'open', type: 'button', class: 'ds-file-cell-open',
329
+ onclick: canOpen ? () => onOpen(f) : null,
330
+ disabled: canOpen ? null : true,
331
+ 'aria-label': typeLabel + ': ' + f.name + (noAccess ? ' (no access)' : ''),
332
+ },
333
+ h('span', { class: 'ds-file-cell-media' },
334
+ thumb
335
+ ? h('img', { class: 'ds-file-cell-thumb', src: thumb, alt: '', loading: 'lazy' })
336
+ : FileIcon({ type: f.type })),
337
+ h('span', { class: 'ds-file-cell-name', title: f.name }, f.name),
338
+ h('span', { class: 'ds-file-cell-meta' }, f.type === 'dir' ? 'folder' : fmtFileSize(f.size))),
339
+ ].filter(Boolean);
340
+ return h('div', {
341
+ key,
342
+ class: 'ds-file-cell' + (marked ? ' is-marked' : '') + (f.active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
343
+ 'data-file-type': f.type,
344
+ }, ...kids);
345
+ }
346
+
347
+ // BulkBar — the act-on-selection strip shown while a multi-select is active.
348
+ // Host renders it above the grid; `actions` are [{ label, onClick, danger,
349
+ // disabled }]; `busy` disables everything while a bulk operation is in flight.
350
+ export function BulkBar({ count = 0, noun = 'file', nounPlural, actions = [], onClear, busy = false } = {}) {
351
+ if (!count) return null;
352
+ // 'entry' pluralizes to 'entries', not 'entrys' - handle the -y noun class
353
+ // unless the host passes an explicit plural.
354
+ const plural = nounPlural || (/[^aeiou]y$/.test(noun) ? noun.slice(0, -1) + 'ies' : noun + 's');
355
+ const kids = [
356
+ h('span', { key: 'count', class: 'ds-bulkbar-count', role: 'status', 'aria-live': 'polite' },
357
+ count + ' ' + (count === 1 ? noun : plural) + ' selected'),
358
+ ...actions.map((a, i) => Btn({
359
+ key: 'bba' + i, danger: !!a.danger, disabled: busy || a.disabled,
360
+ onClick: a.onClick, children: a.label,
361
+ })),
362
+ onClear ? Btn({ key: 'bbclear', disabled: busy, onClick: onClear, children: 'clear selection' }) : null,
363
+ ].filter(Boolean);
364
+ return h('div', { class: 'ds-bulkbar', role: 'toolbar', 'aria-label': 'bulk file actions', 'aria-busy': busy ? 'true' : null }, ...kids);
365
+ }
366
+
232
367
  // Clickable column headers for FileGrid sort. Active column shows its direction
233
368
  // as an ASCII caret word (asc/desc) - never a glyph arrow.
234
369
  function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {