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,153 @@
1
+ // The shared modal shell every file dialog funnels through: Backdrop (focus
2
+ // trap, document-level Escape, invoker focus restore, backdrop dismiss),
3
+ // Modal (head/body/actions slots + a stable aria-labelledby id), and the
4
+ // in-body error line.
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { shortUid } from '../../uid.js';
8
+ const h = webjsx.createElement;
9
+
10
+ // Full focusable set for the modal Tab trap — omitting textarea/select/a[href]
11
+ // lets Tab escape behind the fixed backdrop (fully obscured at mobile sizes).
12
+ const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
13
+
14
+ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
15
+ // webjsx invokes a ref callback with the element on mount and with null on
16
+ // unmount. We stash the per-element keydown teardown on the node itself so
17
+ // the null branch can run it — otherwise the document/element listener leaks
18
+ // once the modal is removed.
19
+ const backdropRef = (el) => {
20
+ if (!el) return; // unmount (ref(null)) handled by wrapper below
21
+ const modal = el.querySelector('.ds-modal');
22
+ if (!modal) return;
23
+
24
+ const handleKeydown = (e) => {
25
+ // Escape closes the modal — unless a mutation is in flight (the live
26
+ // busy state is read off the data-busy attribute, which re-renders;
27
+ // this handler's closure is bound once at mount).
28
+ if (e.key === 'Escape') {
29
+ e.preventDefault();
30
+ if (el.dataset.busy === '1') return;
31
+ if (onClose) onClose();
32
+ return;
33
+ }
34
+ // Focus trap: re-query focusables on each Tab press so that buttons
35
+ // disabled mid-flight (busy state) are excluded from the cycle and
36
+ // do not break tab navigation.
37
+ if (e.key === 'Tab') {
38
+ const focusables = modal.querySelectorAll(FOCUSABLE_SEL);
39
+ if (focusables.length === 0) {
40
+ e.preventDefault();
41
+ return;
42
+ }
43
+ const firstFocusable = focusables[0];
44
+ const lastFocusable = focusables[focusables.length - 1];
45
+ if (e.shiftKey) {
46
+ if (document.activeElement === firstFocusable) {
47
+ e.preventDefault();
48
+ lastFocusable.focus();
49
+ }
50
+ } else {
51
+ if (document.activeElement === lastFocusable) {
52
+ e.preventDefault();
53
+ firstFocusable.focus();
54
+ }
55
+ }
56
+ }
57
+ };
58
+
59
+ // Escape must close the modal no matter where focus sits (re-renders
60
+ // can bounce focus out of the dialog), so listen at document level
61
+ // for the modal's lifetime.
62
+ document.addEventListener('keydown', handleKeydown, true);
63
+ // Record the invoker BEFORE the modal steals focus, so close (confirm,
64
+ // cancel, Escape, backdrop click) restores keyboard/AT focus to where
65
+ // the user was (e.g. the FileGrid row button) instead of <body>.
66
+ // Re-mounts mid-lifetime (every app render re-runs this ref) keep the
67
+ // ORIGINAL invoker and never re-steal focus from the user.
68
+ const invoker = el.contains(document.activeElement) ? (Backdrop._invoker || document.activeElement) : document.activeElement;
69
+ if (!Backdrop._invoker) Backdrop._invoker = invoker;
70
+ el._dsModalTeardown = (removed) => {
71
+ document.removeEventListener('keydown', handleKeydown, true);
72
+ // Only restore focus when the modal is genuinely going away (not a
73
+ // re-render remount) and focus is not already somewhere useful.
74
+ if (removed && Backdrop._invoker && Backdrop._invoker.focus && Backdrop._invoker.isConnected) {
75
+ try { Backdrop._invoker.focus(); } catch { /* swallow: restoring focus on close is best-effort, teardown still completes */ }
76
+ }
77
+ if (removed) Backdrop._invoker = null;
78
+ };
79
+ // Auto-focus on open - only when focus is not already inside the modal
80
+ // (re-renders must not yank the caret around).
81
+ if (!el.contains(document.activeElement)) {
82
+ const preferred = modal.querySelector('[autofocus]') || modal.querySelector(FOCUSABLE_SEL);
83
+ if (preferred) preferred.focus();
84
+ }
85
+ };
86
+
87
+ return h('div', {
88
+ class: 'ds-modal-backdrop',
89
+ // Live busy flag read by the mount-bound Escape handler + backdrop click.
90
+ 'data-busy': busy ? '1' : '0',
91
+ ref: (el) => {
92
+ if (el) {
93
+ // A remount in the same tick (render churn) is not a close:
94
+ // cancel the pending removal teardown before re-binding.
95
+ Backdrop._pendingRemoval = false;
96
+ backdropRef(el);
97
+ Backdrop._last = el;
98
+ } else if (Backdrop._last && Backdrop._last._dsModalTeardown) {
99
+ const t = Backdrop._last._dsModalTeardown;
100
+ Backdrop._last = null;
101
+ Backdrop._pendingRemoval = true;
102
+ t(false); // always unhook the document listener now
103
+ queueMicrotask(() => {
104
+ // Still gone next microtask -> genuine close: restore focus.
105
+ if (Backdrop._pendingRemoval) { t(true); Backdrop._pendingRemoval = false; }
106
+ });
107
+ }
108
+ },
109
+ onclick: (e) => {
110
+ if (e.target !== e.currentTarget) return;
111
+ if (e.currentTarget.dataset.busy === '1') return; // no mid-flight close
112
+ if (onClose) onClose();
113
+ }
114
+ },
115
+ h('div', {
116
+ class: 'ds-modal' + (kind ? ' ds-modal-' + kind : ''),
117
+ role: 'dialog', 'aria-modal': 'true',
118
+ ...(labelledBy ? { 'aria-labelledby': labelledBy } : {})
119
+ }, ...(Array.isArray(children) ? children : [children]))
120
+ );
121
+ }
122
+
123
+ // Shared modal shell: head + body + actions row. ConfirmDialog/PromptDialog/
124
+ // FileViewer all funnel through this so the ds-modal markup is authored once.
125
+ // `actions` is an array of vnodes (already using the Btn primitive). Any of the
126
+ // slots may be omitted.
127
+ export function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
128
+ // Give the head a stable id so the dialog can point aria-labelledby at it,
129
+ // exposing the title as the dialog's accessible name to screen readers.
130
+ // The id is minted once per Modal() call with a short random suffix so it
131
+ // stays constant across re-renders of the same dialog instance — an
132
+ // incrementing counter advances on every render, leaving the previous
133
+ // aria-labelledby reference pointing at a now-absent element.
134
+ const headId = head != null ? ('ds-modal-head-' + shortUid(6)) : null;
135
+ return Backdrop({
136
+ onClose,
137
+ kind,
138
+ busy,
139
+ labelledBy: headId,
140
+ children: [
141
+ head != null ? h('div', { id: headId, class: ('ds-modal-head' + (headClass ? ' ' + headClass : '')), ...headAttrs }, ...(Array.isArray(head) ? head : [head])) : null,
142
+ body != null ? h('div', { class: bodyClass, ...bodyAttrs }, ...(Array.isArray(body) ? body : [body])) : null,
143
+ actions != null ? h('div', { class: 'ds-modal-actions' }, ...(Array.isArray(actions) ? actions : [actions])) : null,
144
+ ].filter(Boolean)
145
+ });
146
+ }
147
+
148
+ // A role=alert error line rendered INSIDE the modal body (so a 409/403 from a
149
+ // mutation is visible at the point of action, inside the focus trap — not a
150
+ // sibling stuck in page flow behind the fixed backdrop).
151
+ export function modalError(error) {
152
+ return error ? h('p', { class: 'ds-modal-error', role: 'alert' }, String(error)) : null;
153
+ }
@@ -0,0 +1,129 @@
1
+ // The three preview body renderers a host drops into FileViewer /
2
+ // FilePreviewPane: media (image with fit/actual toggle, video, audio, or a
3
+ // typed fallback), code (gutter + Prism highlight, optional wrap toggle and
4
+ // source/preview mode switch), and plain text.
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { Icon } from '../shell.js';
8
+ import { fileGlyph } from '../files.js';
9
+ import { highlightAllUnder } from '../../highlight.js';
10
+ const h = webjsx.createElement;
11
+
12
+ export function FilePreviewMedia({ src, type = 'other', name } = {}) {
13
+ if (type === 'image') {
14
+ // Fit-to-pane (default) vs actual-size (1:1) toggle + a checkerboard so
15
+ // transparency reads. The toggle flips a class on the img in-place and
16
+ // reports the natural pixel dimensions into its own caption on load.
17
+ const onToggle = (e) => {
18
+ const wrap = e.currentTarget.closest('.ds-preview-media-wrap');
19
+ const img = wrap && wrap.querySelector('.ds-preview-media');
20
+ if (!img) return;
21
+ const actual = img.classList.toggle('is-actual');
22
+ e.currentTarget.textContent = actual ? 'fit to pane' : 'actual size';
23
+ };
24
+ const onLoad = (e) => {
25
+ const img = e.currentTarget;
26
+ const cap = img.closest('.ds-preview-media-wrap');
27
+ const dim = cap && cap.querySelector('.ds-preview-media-dim');
28
+ if (dim && img.naturalWidth) dim.textContent = img.naturalWidth + ' x ' + img.naturalHeight + ' px';
29
+ };
30
+ return h('div', { class: 'ds-preview-media-wrap' },
31
+ h('img', { class: 'ds-preview-media ds-preview-media-alpha', src, alt: name || '', onload: onLoad }),
32
+ h('div', { class: 'ds-preview-media-controls' },
33
+ h('span', { class: 'ds-preview-media-dim', 'aria-live': 'polite' }, ''),
34
+ h('button', { type: 'button', class: 'chat-code-copy', onclick: onToggle }, 'actual size')));
35
+ }
36
+ if (type === 'video') return h('video', { class: 'ds-preview-media', src, controls: true });
37
+ if (type === 'audio') return h('audio', { class: 'ds-preview-audio', src, controls: true });
38
+ return h('div', { class: 'ds-preview-fallback' },
39
+ h('span', { class: 'ds-preview-glyph', 'aria-hidden': 'true' }, Icon(fileGlyph(type))),
40
+ h('span', {}, 'no inline preview for ' + (type || 'this file'))
41
+ );
42
+ }
43
+
44
+ // FilePreviewCode — the code/source pane. Two additive, opt-in affordances
45
+ // ported from pi-web's FileViewer (behavior only, not its React/SSE plumbing):
46
+ // wrap : host-controlled wrap-lines toggle. Pass `wrap` (current
47
+ // state) + `onWrapToggle` to show the control; omitted host
48
+ // keeps the old always-'pre' behavior (no regression).
49
+ // previewHtml : when the host has already rendered markdown/HTML to a safe
50
+ // HTML string (e.g. via markdown-cache.js's
51
+ // renderMarkdownCached, or a sanitized srcDoc for raw HTML
52
+ // files) it passes it here + `previewLabel` (defaults
53
+ // 'preview') to get a source/preview mode switcher, mirroring
54
+ // pi-web's DisplayMode tabs. This component never renders
55
+ // unsanitized markdown itself — that stays the host's job
56
+ // (chat.js already owns the sanitize+render pipeline).
57
+ export function FilePreviewCode({ content = '', lang, filename, wrap, onWrapToggle, previewHtml, previewLabel = 'preview', mode, onModeChange } = {}) {
58
+ // A filename/lang header matching the chat CodeNode's .chat-code-head, plus
59
+ // the same copy control (chat A1/A2 ship this run, so preview matches for
60
+ // full cross-surface consistency).
61
+ const onCopy = (e) => {
62
+ const btn = e.currentTarget;
63
+ const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
64
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(content).then(done).catch(() => {});
65
+ else { try { const t = document.createElement('textarea'); t.value = content; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch { /* swallow: legacy execCommand copy fallback unsupported, nothing more to try */ } }
66
+ };
67
+ const hasPreview = previewHtml != null && onModeChange;
68
+ const activeMode = hasPreview ? (mode || 'source') : 'source';
69
+ const modeSwitch = hasPreview ? h('div', { class: 'ds-preview-mode-switch', role: 'group', 'aria-label': 'file view mode' },
70
+ h('button', { type: 'button', class: 'ds-preview-mode-btn' + (activeMode === 'source' ? ' active' : ''),
71
+ 'aria-pressed': activeMode === 'source' ? 'true' : 'false', onclick: () => onModeChange('source') }, 'source'),
72
+ h('button', { type: 'button', class: 'ds-preview-mode-btn' + (activeMode === 'preview' ? ' active' : ''),
73
+ 'aria-pressed': activeMode === 'preview' ? 'true' : 'false', onclick: () => onModeChange('preview') }, previewLabel)
74
+ ) : null;
75
+ const wrapCtl = (onWrapToggle && activeMode === 'source') ? h('button', {
76
+ type: 'button', class: 'chat-code-copy ds-preview-wrap-toggle' + (wrap ? ' active' : ''),
77
+ title: wrap ? 'disable word wrap' : 'enable word wrap',
78
+ 'aria-label': wrap ? 'disable word wrap' : 'enable word wrap',
79
+ 'aria-pressed': wrap ? 'true' : 'false',
80
+ onclick: () => onWrapToggle(!wrap),
81
+ }, 'wrap') : null;
82
+ return h('div', { class: 'ds-preview-code-wrap' },
83
+ h('div', { class: 'chat-code-head ds-preview-code-head' },
84
+ h('span', { class: 'lang' }, lang || 'text'),
85
+ filename ? h('span', { class: 'name' }, filename) : null,
86
+ h('span', { class: 'spread' }),
87
+ modeSwitch,
88
+ wrapCtl,
89
+ h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
90
+ activeMode === 'preview'
91
+ // webjsx has no innerHTML prop — set it imperatively via ref, same
92
+ // pattern as chat-message-parts.js/community.js. `previewHtml` is
93
+ // the HOST's already-sanitized HTML (e.g. via markdown-cache.js's
94
+ // renderMarkdownCached, which owns its own sanitize step); this
95
+ // component never sanitizes or fetches on its own.
96
+ ? h('div', { class: 'ds-preview-html', ref: (el) => { if (el) el.innerHTML = previewHtml; } })
97
+ : codeBody({ content, lang, wrap })
98
+ );
99
+ }
100
+
101
+ // The code body: a non-selectable line-number gutter + the highlighted code.
102
+ // A ref triggers Prism over the <code> after mount (the bundle only auto-runs
103
+ // Prism in the chat path), so the file preview is token-colored like Claude
104
+ // Code's file pane. lineNumbers defaults on for code, off for plaintext.
105
+ function codeBody({ content = '', lang, wrap = false } = {}) {
106
+ const wantGutter = !!lang;
107
+ const lineCount = content ? content.split('\n').length : 1;
108
+ const gutter = wantGutter
109
+ ? h('div', { class: 'ds-preview-gutter', 'aria-hidden': 'true' },
110
+ Array.from({ length: lineCount }, (_, i) => String(i + 1)).join('\n'))
111
+ : null;
112
+ const highlightRef = (el) => {
113
+ if (!el) return;
114
+ try { highlightAllUnder(el); } catch { /* swallow: syntax highlighting is a progressive enhancement, plain code still renders */ }
115
+ };
116
+ // wrap: ported from pi-web's wrapLines toggle — pre-wrap + anywhere-break
117
+ // instead of the default horizontal-scroll 'pre', for long unbroken lines
118
+ // (minified JS, long log lines) that are easier to read wrapped.
119
+ return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : '') + (wrap ? ' is-wrapped' : ''), ref: highlightRef },
120
+ gutter,
121
+ h('code', { class: lang ? 'language-' + lang : '' }, content));
122
+ }
123
+
124
+ export function FilePreviewText({ content = '', truncated } = {}) {
125
+ return h('pre', { class: 'ds-preview-text' },
126
+ h('code', {}, content),
127
+ truncated ? h('div', { class: 'ds-preview-truncated' }, '… (truncated)') : null
128
+ );
129
+ }
@@ -0,0 +1,96 @@
1
+ // The two preview containers around a body renderer: FileViewer (the modal
2
+ // overlay, focus-trapped, kept as the narrow-viewport fallback) and
3
+ // FilePreviewPane (the persistent non-modal side pane for a WorkspaceShell
4
+ // pane slot). Both share the head, the ArrowLeft/Right stepper, and the
5
+ // swipe-to-step gesture.
6
+
7
+ import * as webjsx from '../../../vendor/webjsx/index.js';
8
+ import { Icon } from '../shell.js';
9
+ import { fmtFileSize } from '../files.js';
10
+ import { Modal } from './modal-shell.js';
11
+ const h = webjsx.createElement;
12
+
13
+ // Shared preview-head children for both the modal FileViewer and the inline
14
+ // FilePreviewPane: name + meta + prev/next stepper + download + close. ASCII
15
+ // prev/next words (no glyph arrows). onPrev/onNext are omitted when there is no
16
+ // previewable neighbour in that direction.
17
+ function previewHead({ file, onClose, onAction, onPrev, onNext } = {}) {
18
+ const meta = [file.type, file.size != null ? fmtFileSize(file.size) : null, file.modified || null]
19
+ .filter(Boolean).join(' · ');
20
+ return [
21
+ h('span', { class: 'ds-preview-name', title: file.path || file.name || '' }, file.name || ''),
22
+ h('span', { class: 'ds-preview-meta' }, meta),
23
+ h('span', { class: 'ds-preview-actions' },
24
+ (onPrev || onNext) ? h('span', { class: 'ds-preview-step', role: 'group', 'aria-label': 'step files' },
25
+ h('button', { class: 'ds-file-act', title: 'previous file', 'aria-label': 'previous file', disabled: onPrev ? null : true, onclick: () => onPrev && onPrev() }, 'prev'),
26
+ h('button', { class: 'ds-file-act', title: 'next file', 'aria-label': 'next file', disabled: onNext ? null : true, onclick: () => onNext && onNext() }, 'next')) : null,
27
+ onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
28
+ onClose ? h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x')) : null
29
+ )
30
+ ];
31
+ }
32
+
33
+ // ArrowLeft/Right step the preview when focus is inside it (both pane + modal).
34
+ function previewKeyNav(onPrev, onNext) {
35
+ return (e) => {
36
+ if (e.key === 'ArrowLeft' && onPrev) { e.preventDefault(); onPrev(); }
37
+ else if (e.key === 'ArrowRight' && onNext) { e.preventDefault(); onNext(); }
38
+ };
39
+ }
40
+
41
+ // Touch stepping: horizontal swipe on the preview body steps prev/next. Skips
42
+ // when the gesture starts inside a horizontally-scrollable child (code <pre>)
43
+ // so panning wide code never flips files.
44
+ function previewSwipe(onPrev, onNext) {
45
+ if (!onPrev && !onNext) return {};
46
+ let sx = null, sy = null;
47
+ return {
48
+ onpointerdown: (e) => {
49
+ const scroller = e.target.closest && e.target.closest('pre');
50
+ if (scroller && scroller.scrollWidth > scroller.clientWidth) { sx = null; return; }
51
+ sx = e.clientX; sy = e.clientY;
52
+ },
53
+ onpointerup: (e) => {
54
+ if (sx == null) return;
55
+ const dx = e.clientX - sx, dy = e.clientY - sy;
56
+ sx = null;
57
+ if (Math.abs(dx) < 48 || Math.abs(dy) > Math.abs(dx)) return;
58
+ if (dx < 0 && onNext) onNext();
59
+ else if (dx > 0 && onPrev) onPrev();
60
+ },
61
+ onpointercancel: () => { sx = null; },
62
+ };
63
+ }
64
+
65
+ export function FileViewer({ file, body, onClose, onAction, onPrev, onNext } = {}) {
66
+ if (!file) return null;
67
+ const keyNav = previewKeyNav(onPrev, onNext);
68
+ return Modal({
69
+ onClose,
70
+ kind: 'preview',
71
+ headClass: 'ds-preview-head',
72
+ headAttrs: { 'data-file-type': file.type || 'other', onkeydown: keyNav },
73
+ head: previewHead({ file, onClose, onAction, onPrev, onNext }),
74
+ bodyClass: 'ds-preview-body',
75
+ bodyAttrs: { 'data-file-type': file.type || 'other', onkeydown: keyNav, ...previewSwipe(onPrev, onNext) },
76
+ body: Array.isArray(body) ? body : [body],
77
+ });
78
+ }
79
+
80
+ // FilePreviewPane — the SAME preview, but as a persistent, non-modal side pane
81
+ // for the WorkspaceShell's pane slot (the split-view, claude-Desktop file-pane
82
+ // feel). Distinct from the overlay FileViewer (kept as the <900px fallback).
83
+ // Not focus-trapped (it is not modal); ArrowLeft/Right step files when focused.
84
+ export function FilePreviewPane({ file, body, onClose, onAction, onPrev, onNext } = {}) {
85
+ if (!file) {
86
+ return h('div', { class: 'ds-preview-pane ds-preview-pane-empty', role: 'status' },
87
+ h('span', {}, 'Select a file to preview'));
88
+ }
89
+ return h('div', { class: 'ds-preview-pane', role: 'region', 'aria-label': 'file preview: ' + (file.name || ''),
90
+ tabindex: '0', onkeydown: previewKeyNav(onPrev, onNext) },
91
+ h('div', { class: 'ds-preview-head', 'data-file-type': file.type || 'other' },
92
+ ...previewHead({ file, onClose, onAction, onPrev, onNext })),
93
+ h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
94
+ ...(Array.isArray(body) ? body : [body]))
95
+ );
96
+ }