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
@@ -0,0 +1,156 @@
1
+ // Per-entry file primitives: FileRow (list/compact density), FileCell
2
+ // (thumbnail density), and the loading skeleton. Both entry shapes share the
3
+ // same open/select/permission semantics and the same no-interactive-nesting
4
+ // rule — the row is a plain container whose "open" affordance is itself a real
5
+ // <button>, with per-file action buttons as siblings.
6
+
7
+ import * as webjsx from '../../../vendor/webjsx/index.js';
8
+ import { Icon } from '../shell.js';
9
+ import { TYPE_LABELS, FileIcon, fmtFileSize } from './types.js';
10
+ const h = webjsx.createElement;
11
+
12
+ // Default action set for FileRow. A host without mutation endpoints passes a
13
+ // narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
14
+ const FILE_ROW_ACTIONS = ['download', 'rename', 'move', 'delete'];
15
+
16
+ export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
17
+ actions = FILE_ROW_ACTIONS, busy = false, selectable = false, selected = false, onToggleSelect } = {}) {
18
+ // permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
19
+ // be listed (the dir stat saw it) but not opened — show an ASCII tag and
20
+ // disable the open button so the row reads honestly instead of silently
21
+ // failing on click.
22
+ const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
23
+ const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
24
+ const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
25
+ // permTag is rendered as its own chip (a SHAPE channel, not folded into the
26
+ // muted meta text) - so drop it from the meta join, but keep it in the
27
+ // accessible label so AT still announces the restriction.
28
+ const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
29
+ const typeLabel = TYPE_LABELS[type] || 'file';
30
+ const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}${permTag ? ', ' + permTag : ''}`;
31
+ const canOpen = onOpen && !noAccess && !busy;
32
+ // Mutation actions on a read-only/no-access row render disabled (with a
33
+ // 'read-only' title) instead of vanishing, so the affordance reads honestly.
34
+ // `busy` (in-flight mutation) disables every control on the row.
35
+ const mutateDisabled = busy || readOnly || noAccess;
36
+ const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
37
+ key: 'act-' + act,
38
+ type: 'button',
39
+ class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
40
+ title: mutateDisabled && act !== 'download' ? 'read-only' : title,
41
+ 'aria-label': ariaLabel,
42
+ disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
43
+ 'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
44
+ onclick: () => onAction(act),
45
+ }, Icon(icon));
46
+ const actionBtns = onAction ? [
47
+ actions.indexOf('download') !== -1 && type !== 'dir'
48
+ ? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
49
+ actions.indexOf('rename') !== -1
50
+ ? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
51
+ // Single-file move used to require checkbox-select + BulkBar - a
52
+ // per-row affordance matches fsbrowse and the kit rename/delete rows
53
+ // already on this row (no reason move alone needed a select detour).
54
+ actions.indexOf('move') !== -1
55
+ ? actBtn('move', 'move', `move ${name}`, 'arrow-right', false) : null,
56
+ actions.indexOf('delete') !== -1
57
+ ? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
58
+ ].filter(Boolean) : [];
59
+ // Multi-select checkbox — a sibling control before the open button so the
60
+ // row stays valid HTML (no interactive nesting). A no-access entry cannot
61
+ // be marked (bulk mutations would fail on it anyway).
62
+ const checkCtl = selectable ? h('button', {
63
+ key: 'mark',
64
+ type: 'button',
65
+ class: 'ds-file-check' + (selected ? ' is-marked' : ''),
66
+ role: 'checkbox',
67
+ 'aria-checked': selected ? 'true' : 'false',
68
+ 'aria-label': (selected ? 'unselect ' : 'select ') + name,
69
+ disabled: (noAccess || busy) ? true : null,
70
+ onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
71
+ }, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null;
72
+ // A role=button row containing real <button> action controls is invalid
73
+ // HTML (interactive nesting). Instead the row is a plain container and the
74
+ // primary "open" affordance is itself a real <button> (native keyboard +
75
+ // semantics); the per-file action buttons sit alongside it as siblings.
76
+ const rowKids = [
77
+ checkCtl,
78
+ h('button', {
79
+ key: 'open',
80
+ type: 'button',
81
+ class: 'ds-file-open',
82
+ onclick: canOpen ? onOpen : null,
83
+ 'aria-label': accessibleLabel + (noAccess ? ' (no access)' : ''),
84
+ 'aria-pressed': active ? 'true' : 'false',
85
+ disabled: canOpen ? null : true,
86
+ },
87
+ ...[
88
+ code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
89
+ FileIcon({ type }),
90
+ h('span', { class: 'title' }, name),
91
+ h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
92
+ permTag ? h('span', { class: 'ds-file-perm-tag' + (noAccess ? ' is-noaccess' : ''), 'aria-hidden': 'true' }, permTag) : null,
93
+ ].filter(Boolean)
94
+ ),
95
+ actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
96
+ ...actionBtns
97
+ ) : null,
98
+ ].filter(Boolean);
99
+ return h('div', {
100
+ key,
101
+ class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
102
+ + (readOnly ? ' is-restricted' : '')
103
+ + (selected ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
104
+ 'data-file-type': type,
105
+ 'aria-busy': busy ? 'true' : null,
106
+ }, ...rowKids);
107
+ }
108
+
109
+ // FileCell — the thumbnail-density tile. Image entries show a real (lazy)
110
+ // thumbnail through the host's confined thumbUrl; everything else keeps its
111
+ // type icon. Same open/mark semantics as FileRow, same no-nesting rule.
112
+ export function FileCell({ key, f = {}, selectable = false, selected = false, onToggleSelect, onOpen, thumb } = {}) {
113
+ const noAccess = f.locked || f.permissions === 'EACCES'
114
+ || (Array.isArray(f.permissions) && f.permissions.length === 0);
115
+ const canOpen = onOpen && !noAccess;
116
+ const typeLabel = TYPE_LABELS[f.type] || 'file';
117
+ const kids = [
118
+ selectable ? h('button', {
119
+ key: 'mark', type: 'button',
120
+ class: 'ds-file-check ds-file-cell-check' + (selected ? ' is-marked' : ''),
121
+ role: 'checkbox', 'aria-checked': selected ? 'true' : 'false',
122
+ 'aria-label': (selected ? 'unselect ' : 'select ') + f.name,
123
+ disabled: noAccess ? true : null,
124
+ onclick: onToggleSelect ? (e) => onToggleSelect({ range: !!e.shiftKey }) : null,
125
+ }, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null,
126
+ h('button', {
127
+ key: 'open', type: 'button', class: 'ds-file-cell-open',
128
+ onclick: canOpen ? () => onOpen(f) : null,
129
+ disabled: canOpen ? null : true,
130
+ 'aria-label': typeLabel + ': ' + f.name + (noAccess ? ' (no access)' : ''),
131
+ },
132
+ h('span', { class: 'ds-file-cell-media' },
133
+ thumb
134
+ ? h('img', { class: 'ds-file-cell-thumb', src: thumb, alt: '', loading: 'lazy' })
135
+ : FileIcon({ type: f.type })),
136
+ h('span', { class: 'ds-file-cell-name', title: f.name }, f.name),
137
+ h('span', { class: 'ds-file-cell-meta' }, f.type === 'dir' ? 'folder' : fmtFileSize(f.size))),
138
+ ].filter(Boolean);
139
+ return h('div', {
140
+ key,
141
+ class: 'ds-file-cell' + (selected ? ' is-marked' : '') + (f.active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
142
+ 'data-file-type': f.type,
143
+ }, ...kids);
144
+ }
145
+
146
+ // FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
147
+ // grid does not flash from a bare spinner to a full list (predictable perceived
148
+ // perf, the file-manager feel). `rows` controls how many ghost rows render.
149
+ export function FileSkeleton({ rows = 12 } = {}) {
150
+ return h('div', { class: 'ds-file-grid ds-file-skeleton', role: 'status', 'aria-busy': 'true', 'aria-label': 'loading files' },
151
+ ...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-file-row ds-file-row-skeleton', 'aria-hidden': 'true' },
152
+ h('span', { class: 'ds-skel ds-skel-icon' }),
153
+ h('span', { class: 'ds-skel ds-skel-title' }),
154
+ h('span', { class: 'ds-skel ds-skel-meta' })))
155
+ );
156
+ }
@@ -0,0 +1,63 @@
1
+ // FileGrid toolbar controls: the density radiogroup (list / compact /
2
+ // thumbnails), the clickable sort-column header, and the WAI-ARIA roving-radio
3
+ // keyboard helper both share.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Icon } from '../shell.js';
7
+ const h = webjsx.createElement;
8
+
9
+ export const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
10
+ export const DENSITY_ICONS = { list: 'rows', compact: 'rows-tight', thumb: 'grid' };
11
+
12
+ // Roving-radiogroup keyboard helper (the WAI-ARIA radio pattern): a radiogroup
13
+ // is a SINGLE tab stop where Arrow/Home/End move AND select among options, with
14
+ // selection following focus. `items` is the ordered [[key, ...], ...] list;
15
+ // `onSelect(targetKey)` is the same handler the onclick fires. Mouse path is
16
+ // unchanged - this only adds keyboard navigation.
17
+ export function rovingRadio(e, idx, items, onSelect) {
18
+ let target = -1;
19
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') target = (idx - 1 + items.length) % items.length;
20
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') target = (idx + 1) % items.length;
21
+ else if (e.key === 'Home') target = 0;
22
+ else if (e.key === 'End') target = items.length - 1;
23
+ else return;
24
+ e.preventDefault();
25
+ onSelect(items[target][0]);
26
+ const sib = e.currentTarget.parentNode && e.currentTarget.parentNode.children[target];
27
+ sib && sib.focus();
28
+ }
29
+
30
+ // Clickable column headers for FileGrid sort. Active column shows its direction
31
+ // as an ASCII caret word (asc/desc) - never a glyph arrow.
32
+ export function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
33
+ const cols = [['name', 'name'], ['size', 'size'], ['modified', 'modified']];
34
+ return h('div', { class: 'ds-file-sort', role: 'group', 'aria-label': 'sort files' },
35
+ ...cols.map(([k, label]) => h('button', {
36
+ key: k, type: 'button',
37
+ class: 'ds-file-sort-btn' + (active === k ? ' active' : ''),
38
+ 'aria-pressed': active === k ? 'true' : 'false',
39
+ 'aria-label': 'sort by ' + label + (active === k ? ' (' + (dir === 'asc' ? 'ascending' : 'descending') + ')' : ''),
40
+ onclick: () => onSort && onSort(k),
41
+ }, label + (active === k ? ' ' + (dir === 'asc' ? 'asc' : 'desc') : ''))));
42
+ }
43
+
44
+ // Density picker — list / compact / thumbnails. A radiogroup, not tabs:
45
+ // it switches presentation of the same content, not panels.
46
+ export function DensityPicker({ density = 'list', onDensity } = {}) {
47
+ if (!onDensity) return null;
48
+ return h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
49
+ ...DENSITIES.map(([k, label], idx) => h('button', {
50
+ key: 'd-' + k, type: 'button', role: 'radio',
51
+ class: 'ds-density-btn' + (density === k ? ' active' : ''),
52
+ 'aria-checked': density === k ? 'true' : 'false',
53
+ // Icon-led, but the density name stays the accessible name
54
+ // (aria-label) + the native tooltip (title) so the control reads
55
+ // dense without losing its label.
56
+ 'aria-label': label, title: label,
57
+ // Single tab stop: the checked radio is tabbable, the rest are
58
+ // roved. Arrow/Home/End move + select (selection follows focus).
59
+ tabindex: density === k ? '0' : '-1',
60
+ onkeydown: (e) => rovingRadio(e, idx, DENSITIES, (tk) => { if (density !== tk) onDensity(tk); }),
61
+ onclick: () => { if (density !== k) onDensity(k); },
62
+ }, Icon(DENSITY_ICONS[k], { size: 15 }))));
63
+ }
@@ -0,0 +1,177 @@
1
+ // FileGrid — the directory listing: cold-load skeleton, in-grid sort/filter
2
+ // toolbar, tri-state select-all, roving keyboard focus over the open buttons,
3
+ // a render cap with a "show N more" tail, and list/compact/thumb density.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Icon } from '../shell.js';
7
+ import { FileRow, FileCell, FileSkeleton } from './entries.js';
8
+ import { FileSortHeader, DensityPicker } from './grid-controls.js';
9
+ import { EmptyState } from './chrome.js';
10
+ const h = webjsx.createElement;
11
+
12
+ // FileGrid — the directory listing. Optional in-grid sort + filter make it a
13
+ // real file manager rather than a static dump:
14
+ // sort : { key, dir, onSort(key) } - clickable column headers (name/size/modified)
15
+ // filter : { value, onInput, placeholder } - a quick in-dir name filter
16
+ // onOpen(f) opens a row; onAction(act,f) wires the per-row download/rename/delete.
17
+ // Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
18
+ // row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
19
+ // focus state; the grid tracks it on the DOM via roving tabindex.
20
+ // How many rows to render before the "show more" cap kicks in. A node_modules-
21
+ // scale directory would otherwise flood the DOM with thousands of rows (and make
22
+ // the roving-tabindex querySelectorAll scan O(n) per keypress). Render the first
23
+ // CAP and a "show N more" row, mirroring the History tab's "load N older".
24
+ const FILE_GRID_CAP = 200;
25
+
26
+ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet', emptyAction,
27
+ sort, filter, loading = false,
28
+ shown, onShowMore, actions, busy,
29
+ // Canonical multi-select contract (shared with
30
+ // SessionDashboard): selected/onToggleSelect.
31
+ // marked/onMark are accepted FileGrid aliases.
32
+ selectable = false, selected, onToggleSelect,
33
+ marked = selected, onMark = onToggleSelect,
34
+ onSelectAll, onClearSelection,
35
+ density = 'list', onDensity, thumbUrl } = {}) {
36
+ // Skeleton ONLY for a cold load. A refresh of a populated grid (rename /
37
+ // delete / upload round-trip) keeps the rows on screen and dims them -
38
+ // flashing the whole directory to shimmer rows on every mutation reads as
39
+ // data loss.
40
+ if (loading && !files.length) return FileSkeleton({ rows: 12 });
41
+ // A filtered miss is NOT an empty directory: when the in-grid filter narrows
42
+ // to zero matches, the host still passes an empty `files` array - but we must
43
+ // keep the controls toolbar (the filter input that caused the miss) mounted so
44
+ // the user can clear/edit it to recover. Only a genuinely-empty directory (no
45
+ // active filter) gets the bare cold EmptyState early-return.
46
+ const hasFilter = !!(filter && (filter.value || '').length > 0);
47
+ if (!files.length && !hasFilter) return EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }), action: emptyAction });
48
+ const refreshing = loading && files.length > 0;
49
+ // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
50
+ // so "show more" can grow it; otherwise default to FILE_GRID_CAP.
51
+ const limit = shown != null ? shown : FILE_GRID_CAP;
52
+ const capped = files.length > limit;
53
+ const visible = capped ? files.slice(0, limit) : files;
54
+ const isThumb = density === 'thumb';
55
+ // NOTE: the old `columns`-driven data-columns card-mode was removed - it placed
56
+ // flex list-rows into a 2-4 col grid (squashed rows, mis-sized actions) and was
57
+ // a half-wired third layout never exposed by the density radiogroup (list/
58
+ // compact/thumb). Thumb density is the canonical multi-column grid.
59
+ const gridAttrs = {};
60
+ // Multi-select bookkeeping. Entries are keyed by path (fallback name); a
61
+ // locked/EACCES entry is never selectable — bulk mutations would fail on it.
62
+ const entryKeyOf = (f) => f.path || f.name;
63
+ const isLockedEntry = (f) => f.locked || f.permissions === 'EACCES'
64
+ || (Array.isArray(f.permissions) && f.permissions.length === 0);
65
+ const selSet = marked instanceof Set ? marked : new Set(marked || []);
66
+ const selectableKeys = selectable ? visible.filter((f) => !isLockedEntry(f)).map(entryKeyOf) : [];
67
+ // Keyboard: roving focus over the open buttons inside the grid (rows and
68
+ // thumbnail cells share the pattern). Ctrl/Cmd+A selects all SHOWN rows.
69
+ const onKeyDown = (e) => {
70
+ const grid = e.currentTarget;
71
+ const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled]), .ds-file-cell-open:not([disabled])'));
72
+ const cur = opens.indexOf(document.activeElement);
73
+ if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
74
+ else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
75
+ else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
76
+ else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
77
+ else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
78
+ else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)
79
+ && selectable && onSelectAll && selectableKeys.length) {
80
+ e.preventDefault(); onSelectAll(selectableKeys);
81
+ }
82
+ };
83
+ const head = sort ? FileSortHeader(sort) : null;
84
+ // Tri-state select-all over the selectable SHOWN rows (the cap label below
85
+ // already tells the user more rows exist beyond the window).
86
+ const selOfVisible = selectableKeys.filter((k) => selSet.has(k)).length;
87
+ const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableKeys.length ? 'true' : 'mixed');
88
+ const selectAllCtl = (selectable && onSelectAll && selectableKeys.length)
89
+ ? h('button', { key: 'selall', type: 'button', class: 'ds-file-selectall', role: 'checkbox',
90
+ 'aria-checked': allState,
91
+ 'aria-label': allState === 'true' ? 'clear selection' : 'select all ' + selectableKeys.length + ' shown files',
92
+ onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableKeys) },
93
+ h('span', { class: 'ds-check-box', 'aria-hidden': 'true' }),
94
+ h('span', {}, 'all'))
95
+ : null;
96
+ const densityCtl = DensityPicker({ density, onDensity });
97
+ // One toolbar baseline: filter + select-all + sort sit left, density is
98
+ // pushed right by the spread. The filter used to be a separate right-aligned
99
+ // strip ABOVE controls, giving two strips with conflicting alignment.
100
+ const filterCtl = filter ? h('span', { key: 'filterwrap', class: 'ds-file-filter-wrap' },
101
+ h('input', {
102
+ key: 'filter',
103
+ class: 'ds-file-filter-input', type: 'search',
104
+ value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
105
+ 'aria-label': filter.placeholder || 'Filter files in this directory',
106
+ oninput: (e) => filter.onInput && filter.onInput(e.target.value),
107
+ onkeydown: (e) => {
108
+ if (e.key === 'Escape' && filter.value) { e.preventDefault(); e.stopPropagation(); filter.onInput && filter.onInput(''); }
109
+ },
110
+ }),
111
+ // Announces the filtered count as the filter narrows the list, so a
112
+ // screen-reader user gets the same feedback a sighted user reads off
113
+ // the grid without having to re-scan it after every keystroke. `files`
114
+ // here is already the host's filter-applied set (see hasFilter above) -
115
+ // there is no separate pre-filter total available inside this component.
116
+ h('span', { key: 'filtercount', class: 'sr-only', role: 'status', 'aria-live': 'polite' },
117
+ hasFilter ? files.length + (files.length === 1 ? ' file' : ' files') + ' shown' : '')
118
+ ) : null;
119
+ const leftKids = [filterCtl, selectAllCtl, head].filter(Boolean);
120
+ const controlsKids = [
121
+ ...leftKids,
122
+ (leftKids.length && densityCtl) ? h('span', { key: 'spread', class: 'spread' }) : null,
123
+ densityCtl].filter(Boolean);
124
+ const controls = controlsKids.length
125
+ ? h('div', { class: 'ds-file-controls' }, ...controlsKids)
126
+ : null;
127
+ // A filtered miss (zero rows but an active filter) renders the EmptyState
128
+ // INSIDE the listing, below the controls toolbar, so the filter input stays
129
+ // mounted and editable - the user can clear/edit it to recover instead of
130
+ // being stranded with no toolbar (the early-return only fires for a genuinely
131
+ // empty directory). The host passes filter-aware copy via emptyText.
132
+ const filteredEmpty = !files.length && hasFilter;
133
+ // role=group not listbox: the rows contain real <button> action controls, so
134
+ // listbox/option semantics are invalid (an option can't host interactive
135
+ // children). Keyboard nav still works via roving focus over the open buttons.
136
+ const grid = filteredEmpty ? EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }) }) : h('div', {
137
+ class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : '') + (refreshing ? ' is-refreshing' : ''),
138
+ role: 'group', 'aria-label': 'files', tabindex: '0',
139
+ 'aria-busy': refreshing ? 'true' : 'false',
140
+ // Always concrete (webjsx's attribute diff can leave a null-valued
141
+ // attribute unset when toggling away from the default).
142
+ 'data-density': density || 'list',
143
+ onkeydown: onKeyDown, ...gridAttrs },
144
+ ...visible.map((f, i) => isThumb
145
+ ? FileCell({
146
+ key: f.path || f.name + i, f,
147
+ selectable, selected: selSet.has(entryKeyOf(f)),
148
+ onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
149
+ onOpen,
150
+ thumb: (thumbUrl && f.type === 'image') ? thumbUrl(f) : null,
151
+ })
152
+ : FileRow({
153
+ key: f.path || f.name + i,
154
+ name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
155
+ permissions: f.permissions, locked: f.locked,
156
+ actions: actions != null ? actions : undefined,
157
+ busy: busy != null ? !!busy : !!f.busy,
158
+ selectable, selected: selSet.has(entryKeyOf(f)),
159
+ onToggleSelect: onMark ? (opts) => onMark(f, opts) : null,
160
+ onOpen: onOpen ? () => onOpen(f) : null,
161
+ onAction: onAction ? (act) => onAction(act, f) : null
162
+ }))
163
+ );
164
+ // A count + "show more" affordance so a capped large dir reads as "more
165
+ // exist", not "this is everything". aria-live announces the shown/total.
166
+ const more = capped
167
+ ? h('div', { class: 'ds-file-more' },
168
+ h('span', { class: 'ds-file-more-count', role: 'status', 'aria-live': 'polite' },
169
+ 'showing ' + visible.length + ' of ' + files.length),
170
+ onShowMore ? h('button', { type: 'button', class: 'ds-file-more-btn',
171
+ onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
172
+ 'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
173
+ : null;
174
+ return (controls || more)
175
+ ? h('div', { class: 'ds-file-listing' }, controls, grid, more)
176
+ : grid;
177
+ }
@@ -0,0 +1,69 @@
1
+ // File-type vocabulary: the canonical type list, its line-icon and accessible
2
+ // label maps, the FileIcon component, and the one byte formatter the whole kit
3
+ // renders sizes through (chat.js re-exports it as fmtBytes).
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Icon } from '../shell.js';
7
+ const h = webjsx.createElement;
8
+
9
+ export const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
10
+ const TYPE_ICON = {
11
+ dir: 'folder', image: 'file-image', video: 'file-video', audio: 'file-audio', code: 'file-code',
12
+ text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'link', other: 'file'
13
+ };
14
+
15
+ export const TYPE_LABELS = {
16
+ dir: 'folder',
17
+ image: 'image file',
18
+ video: 'video file',
19
+ audio: 'audio file',
20
+ code: 'code file',
21
+ text: 'text file',
22
+ archive: 'archive file',
23
+ document: 'document file',
24
+ symlink: 'symbolic link',
25
+ other: 'file'
26
+ };
27
+
28
+ export function fileGlyph(type) {
29
+ return TYPE_ICON[type] || TYPE_ICON.other;
30
+ }
31
+
32
+ // The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
33
+ // format everywhere: '0 B' for zero; the em-dash means unknown/null/invalid
34
+ // (NaN, a negative count, or anything non-numeric never reaches the divide
35
+ // loop — previously NaN fell through to the loop unchanged and rendered the
36
+ // literal string "NaN B").
37
+ export function fmtFileSize(bytes) {
38
+ if (bytes == null) return '—';
39
+ if (typeof bytes !== 'number' || Number.isNaN(bytes) || bytes < 0) return '—';
40
+ if (bytes === 0) return '0 B';
41
+ const u = ['B', 'KB', 'MB', 'GB', 'TB'];
42
+ let i = 0, n = bytes;
43
+ while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
44
+ return n.toFixed(i === 0 ? 0 : 1) + ' ' + u[i];
45
+ }
46
+
47
+ export function FileIcon({ type = 'other' } = {}) {
48
+ return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
49
+ }
50
+
51
+ // Sort a file list by a key (name/size/modified/type), dirs-first always so the
52
+ // hierarchy reads top-down regardless of sort. `dir` is 'asc'|'desc'.
53
+ // `modifiedTs` (epoch ms) is used for the modified sort when present, since the
54
+ // `modified` field is a pre-formatted relative string the host passes for display.
55
+ export function sortFiles(files = [], sort = 'name', dir = 'asc') {
56
+ const mul = dir === 'desc' ? -1 : 1;
57
+ const cmp = (a, b) => {
58
+ // Directories always cluster before files; within a cluster, apply the sort.
59
+ const ad = a.type === 'dir' ? 0 : 1, bd = b.type === 'dir' ? 0 : 1;
60
+ if (ad !== bd) return ad - bd;
61
+ let r = 0;
62
+ if (sort === 'size') r = (a.size || 0) - (b.size || 0);
63
+ else if (sort === 'modified') r = (a.modifiedTs || 0) - (b.modifiedTs || 0);
64
+ else if (sort === 'type') r = String(a.type || '').localeCompare(String(b.type || ''));
65
+ else r = String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
66
+ return r * mul || String(a.name || '').localeCompare(String(b.name || ''));
67
+ };
68
+ return files.slice().sort(cmp);
69
+ }
@@ -0,0 +1,118 @@
1
+ // The three question dialogs, all composed on the shared Modal shell so they
2
+ // inherit its focus trap and Escape/backdrop dismiss: ConfirmDialog (yes/no,
3
+ // optionally destructive), PromptDialog (single text input with optional root
4
+ // chips), and CountdownDialog (ticking auto-expire).
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { Btn } from '../shell.js';
8
+ import { Modal, modalError } from './modal-shell.js';
9
+ const h = webjsx.createElement;
10
+
11
+ // `error` renders inside .ds-modal-body (role=alert, error tone). `busy`
12
+ // disables both action buttons AND the Escape/backdrop close paths; the confirm
13
+ // label flips to `busyLabel` (default 'working…') so the in-flight state reads.
14
+ export function ConfirmDialog({ title = 'Are you sure?', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel, error, busy = false, busyLabel = 'working…' } = {}) {
15
+ return Modal({
16
+ onClose: onCancel,
17
+ kind: 'small',
18
+ busy,
19
+ head: title,
20
+ body: [message || '', modalError(error)].filter(Boolean),
21
+ actions: [
22
+ Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
23
+ Btn({ variant: destructive ? 'danger' : 'primary', disabled: busy, onClick: onConfirm, children: busy ? busyLabel : confirmLabel })
24
+ ]
25
+ });
26
+ }
27
+
28
+ export function PromptDialog({ title = 'Enter a name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…', roots, onPickRoot } = {}) {
29
+ // Optional one-click starting-point chips (e.g. a destination-path prompt
30
+ // for a filesystem with more than one allowed root) - a user typing a
31
+ // path has no way to discover what a second disjoint root even looks
32
+ // like otherwise. Each { path, label } fills the input via the same
33
+ // onInput callback a manual keystroke would.
34
+ const rootsRow = (roots && roots.length)
35
+ ? h('div', { class: 'ds-prompt-roots', role: 'group', 'aria-label': 'accessible folders' },
36
+ ...roots.map((r, i) => h('button', {
37
+ key: 'pr' + i, type: 'button', class: 'ds-prompt-root-chip',
38
+ onclick: () => { const p = r.path || r; if (onPickRoot) onPickRoot(p); else if (onInput) onInput(p); },
39
+ }, r.label || r.path || r)))
40
+ : null;
41
+ return Modal({
42
+ onClose: onCancel,
43
+ kind: 'small',
44
+ busy,
45
+ head: title,
46
+ body: [h('input', {
47
+ class: 'input ds-modal-input',
48
+ type: 'text',
49
+ value,
50
+ placeholder,
51
+ autofocus: true,
52
+ disabled: busy ? true : null,
53
+ 'aria-invalid': error ? 'true' : null,
54
+ oninput: (e) => onInput && onInput(e.target.value),
55
+ onkeydown: (e) => {
56
+ // IME guard: the Enter that commits a CJK composition must not confirm.
57
+ if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); if (!busy) onConfirm && onConfirm(e.target.value); }
58
+ if (e.key === 'Escape') { e.preventDefault(); if (!busy) onCancel && onCancel(); }
59
+ }
60
+ }), rootsRow, modalError(error)].filter(Boolean),
61
+ actions: [
62
+ Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
63
+ Btn({
64
+ primary: true,
65
+ disabled: busy,
66
+ // Read the live input value, not the closed-over `value` prop:
67
+ // consumers update their state in oninput without re-rendering
68
+ // (to avoid caret jump), so the prop is stale at click time.
69
+ onClick: (e) => {
70
+ if (!onConfirm) return;
71
+ const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
72
+ onConfirm(inp ? inp.value : value);
73
+ },
74
+ children: busy ? busyLabel : confirmLabel
75
+ })
76
+ ]
77
+ });
78
+ }
79
+
80
+ // CountdownDialog — a modal with a role=status line ticking down from
81
+ // `seconds` to 0 once per second, auto-firing onExpire at zero. Composes on
82
+ // top of the same Modal() shell ConfirmDialog/PromptDialog use, so it
83
+ // inherits Backdrop's focus-trap + Escape/backdrop-dismiss handling for free
84
+ // rather than reimplementing dialog plumbing.
85
+ export function CountdownDialog({ title = 'Are you sure?', message, seconds = 10, onExpire, actions } = {}) {
86
+ const startSeconds = Math.max(0, Math.floor(seconds));
87
+ return Modal({
88
+ onClose: undefined, // no implicit dismiss path unless the caller supplies one via `actions`
89
+ kind: 'small',
90
+ head: title,
91
+ body: [
92
+ message || '',
93
+ h('p', {
94
+ class: 'ds-countdown-status', role: 'status', 'aria-live': 'polite',
95
+ ref: (el) => {
96
+ if (!el || el._dsCountdownTimer) return;
97
+ let remaining = startSeconds;
98
+ const render = () => { el.textContent = remaining + (remaining === 1 ? ' second' : ' seconds') + ' remaining'; };
99
+ render();
100
+ el._dsCountdownTimer = setInterval(() => {
101
+ remaining -= 1;
102
+ if (remaining <= 0) {
103
+ clearInterval(el._dsCountdownTimer);
104
+ el._dsCountdownTimer = null;
105
+ remaining = 0;
106
+ render();
107
+ if (onExpire) onExpire();
108
+ return;
109
+ }
110
+ render();
111
+ }, 1000);
112
+ },
113
+ }),
114
+ modalError(null),
115
+ ].filter(Boolean),
116
+ actions: actions || [],
117
+ });
118
+ }