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,106 +1,15 @@
1
- // Freddie configuration pages: `models` (availability matrix + rebuild),
2
- // `skills`, `plugins`, `config` (runtime settings + skin), and `env`
3
- // (provider api keys, write-only, never echoed back).
1
+ // Freddie settings pages: `config` (runtime configuration + active skin) and
2
+ // `env` (provider api keys — write-only, the server returns only a masked
3
+ // fingerprint, never the key itself).
4
4
 
5
5
  import * as webjsx from '../../../vendor/webjsx/index.js';
6
6
  import { makePage, api, loadingState, errorState, emptyState } from './runtime.js';
7
7
  import { Row, Table, PageHeader, TextField, Select } from '../content.js';
8
8
  import { Chip, Btn } from '../shell.js';
9
- import { ModelsConfig } from '../models-config.js';
10
- import { SkillsConfig } from '../skills-config.js';
11
- import { PluginsConfig } from '../plugins-config.js';
12
9
  import { section, noteAlert, liveRegion } from './shared.js';
13
10
 
14
11
  const h = webjsx.createElement;
15
12
 
16
- export const models = makePage((ctx) => {
17
- Object.assign(ctx.state, { rebuilding: false, selectedProviderId: null, selectedModel: null });
18
- // GET /api/models/availability — the real per-(provider x model x mode)
19
- // availability matrix (plugins/gui-models-discover), per freddie's AGENTS.md
20
- // "Model availability matrix" section. 404 with {error,hint} when the
21
- // matrix file hasn't been built yet — ModelsConfig itself renders that
22
- // as an empty state with a "build availability matrix" action.
23
- async function load() {
24
- try { ctx.set({ loading: false, data: await api('/api/models/availability'), error: null }); }
25
- catch (e) { ctx.set({ loading: false, data: null, error: (e && e.body) || e }); }
26
- }
27
- async function rebuild() {
28
- if (ctx.state.rebuilding) return;
29
- ctx.set({ rebuilding: true, rebuildError: null });
30
- try { await api('/api/models/availability/rebuild', { method: 'POST', body: {} }); await load(); }
31
- catch (e) { ctx.set({ rebuildError: e }); }
32
- ctx.set({ rebuilding: false });
33
- }
34
- load();
35
- return () => {
36
- const s = ctx.state;
37
- return [
38
- PageHeader({ title: 'models', lede: s.data ? (s.data.summary?.total_models ?? 0) + ' models across ' + (s.data.summary?.total_providers ?? 0) + ' providers' : 'model availability matrix' }),
39
- ModelsConfig({
40
- data: s.data, loading: s.loading, error: s.error,
41
- selectedProviderId: s.selectedProviderId, onSelectProvider: (id) => ctx.set({ selectedProviderId: id, selectedModel: null }),
42
- selectedModel: s.selectedModel, onSelectModel: (m) => ctx.set({ selectedModel: m }),
43
- onRefresh: load, onRebuild: rebuild, rebuilding: s.rebuilding, rebuildError: s.rebuildError,
44
- }),
45
- ];
46
- };
47
- });
48
-
49
- export const skills = makePage((ctx) => {
50
- Object.assign(ctx.state, { selected: null, query: '', busyName: null });
51
- async function load() { try { ctx.set({ loading: false, list: await api('/api/skills'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
52
- load();
53
- return () => {
54
- const s = ctx.state;
55
- // GET /api/skills returns {home:[...], bundled:[...], skillState} —
56
- // two source lists (user ~/.freddie/skills vs bundled skills/ dirs)
57
- // plus a per-skill enabled/disabled state map, not a flat array.
58
- // Concat both sources (home overrides bundled on name collision,
59
- // matching src/skills/index.js's own findSkill() precedence) and
60
- // resolve enabled state from skillState (default true when absent).
61
- const raw = s.list && typeof s.list === 'object' ? s.list : {};
62
- const rawList = Array.isArray(raw) ? raw : [...(raw.bundled || []), ...(raw.home || [])];
63
- const skillState = raw.skillState || {};
64
- const mapped = rawList.map((sk) => ({
65
- file: sk.file || sk.path || sk.name,
66
- name: sk.name,
67
- description: sk.description || (sk.frontmatter && sk.frontmatter.description) || '',
68
- platforms: sk.platforms || (sk.frontmatter && sk.frontmatter.platforms),
69
- enabled: skillState[sk.name] !== false,
70
- }));
71
- return [
72
- PageHeader({ title: 'skills', lede: mapped.length + ' skills' }),
73
- SkillsConfig({
74
- skills: mapped, selected: s.selected, loading: s.loading, error: s.error,
75
- busyName: s.busyName, query: s.query, onQuery: (q) => ctx.set({ query: q }),
76
- onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
77
- }),
78
- ];
79
- };
80
- });
81
-
82
- export const plugins = makePage((ctx) => {
83
- Object.assign(ctx.state, { selected: null });
84
- // GET /api/plugins — flat {name,version,surfaces,requires,source,enabled}
85
- // list, per plugins/gui-plugins-list/plugin.js (distinct from
86
- // /api/plugin-graph's D3 {nodes,edges} shape built for the dependency
87
- // visualization, not a flat list UI).
88
- async function load() { try { ctx.set({ loading: false, list: await api('/api/plugins'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
89
- load();
90
- return () => {
91
- const s = ctx.state;
92
- const list = Array.isArray(s.list) ? s.list : (s.list?.plugins || []);
93
- return [
94
- PageHeader({ title: 'plugins', lede: list.length + ' plugins loaded' }),
95
- PluginsConfig({
96
- plugins: list, selected: s.selected, loading: s.loading, error: s.error,
97
- onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
98
- onReload: load,
99
- }),
100
- ];
101
- };
102
- });
103
-
104
13
  export const config = makePage((ctx) => {
105
14
  Object.assign(ctx.state, { edited: {}, busy: false, note: null });
106
15
  async function load() {
@@ -0,0 +1,97 @@
1
+ // Freddie capability-catalogue pages, each a thin state wrapper over its own
2
+ // dedicated config component: `models` (availability matrix + rebuild),
3
+ // `skills`, and `plugins`.
4
+
5
+ import { makePage, api } from './runtime.js';
6
+ import { PageHeader } from '../content.js';
7
+ import { ModelsConfig } from '../models-config.js';
8
+ import { SkillsConfig } from '../skills-config.js';
9
+ import { PluginsConfig } from '../plugins-config.js';
10
+
11
+ export const models = makePage((ctx) => {
12
+ Object.assign(ctx.state, { rebuilding: false, selectedProviderId: null, selectedModel: null });
13
+ // GET /api/models/availability — the real per-(provider x model x mode)
14
+ // availability matrix (plugins/gui-models-discover), per freddie's AGENTS.md
15
+ // "Model availability matrix" section. 404 with {error,hint} when the
16
+ // matrix file hasn't been built yet — ModelsConfig itself renders that
17
+ // as an empty state with a "build availability matrix" action.
18
+ async function load() {
19
+ try { ctx.set({ loading: false, data: await api('/api/models/availability'), error: null }); }
20
+ catch (e) { ctx.set({ loading: false, data: null, error: (e && e.body) || e }); }
21
+ }
22
+ async function rebuild() {
23
+ if (ctx.state.rebuilding) return;
24
+ ctx.set({ rebuilding: true, rebuildError: null });
25
+ try { await api('/api/models/availability/rebuild', { method: 'POST', body: {} }); await load(); }
26
+ catch (e) { ctx.set({ rebuildError: e }); }
27
+ ctx.set({ rebuilding: false });
28
+ }
29
+ load();
30
+ return () => {
31
+ const s = ctx.state;
32
+ return [
33
+ PageHeader({ title: 'models', lede: s.data ? (s.data.summary?.total_models ?? 0) + ' models across ' + (s.data.summary?.total_providers ?? 0) + ' providers' : 'model availability matrix' }),
34
+ ModelsConfig({
35
+ data: s.data, loading: s.loading, error: s.error,
36
+ selectedProviderId: s.selectedProviderId, onSelectProvider: (id) => ctx.set({ selectedProviderId: id, selectedModel: null }),
37
+ selectedModel: s.selectedModel, onSelectModel: (m) => ctx.set({ selectedModel: m }),
38
+ onRefresh: load, onRebuild: rebuild, rebuilding: s.rebuilding, rebuildError: s.rebuildError,
39
+ }),
40
+ ];
41
+ };
42
+ });
43
+
44
+ export const skills = makePage((ctx) => {
45
+ Object.assign(ctx.state, { selected: null, query: '', busyName: null });
46
+ async function load() { try { ctx.set({ loading: false, list: await api('/api/skills'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
47
+ load();
48
+ return () => {
49
+ const s = ctx.state;
50
+ // GET /api/skills returns {home:[...], bundled:[...], skillState} —
51
+ // two source lists (user ~/.freddie/skills vs bundled skills/ dirs)
52
+ // plus a per-skill enabled/disabled state map, not a flat array.
53
+ // Concat both sources (home overrides bundled on name collision,
54
+ // matching src/skills/index.js's own findSkill() precedence) and
55
+ // resolve enabled state from skillState (default true when absent).
56
+ const raw = s.list && typeof s.list === 'object' ? s.list : {};
57
+ const rawList = Array.isArray(raw) ? raw : [...(raw.bundled || []), ...(raw.home || [])];
58
+ const skillState = raw.skillState || {};
59
+ const mapped = rawList.map((sk) => ({
60
+ file: sk.file || sk.path || sk.name,
61
+ name: sk.name,
62
+ description: sk.description || (sk.frontmatter && sk.frontmatter.description) || '',
63
+ platforms: sk.platforms || (sk.frontmatter && sk.frontmatter.platforms),
64
+ enabled: skillState[sk.name] !== false,
65
+ }));
66
+ return [
67
+ PageHeader({ title: 'skills', lede: mapped.length + ' skills' }),
68
+ SkillsConfig({
69
+ skills: mapped, selected: s.selected, loading: s.loading, error: s.error,
70
+ busyName: s.busyName, query: s.query, onQuery: (q) => ctx.set({ query: q }),
71
+ onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
72
+ }),
73
+ ];
74
+ };
75
+ });
76
+
77
+ export const plugins = makePage((ctx) => {
78
+ Object.assign(ctx.state, { selected: null });
79
+ // GET /api/plugins — flat {name,version,surfaces,requires,source,enabled}
80
+ // list, per plugins/gui-plugins-list/plugin.js (distinct from
81
+ // /api/plugin-graph's D3 {nodes,edges} shape built for the dependency
82
+ // visualization, not a flat list UI).
83
+ async function load() { try { ctx.set({ loading: false, list: await api('/api/plugins'), error: null }); } catch (e) { ctx.set({ loading: false, error: e }); } }
84
+ load();
85
+ return () => {
86
+ const s = ctx.state;
87
+ const list = Array.isArray(s.list) ? s.list : (s.list?.plugins || []);
88
+ return [
89
+ PageHeader({ title: 'plugins', lede: list.length + ' plugins loaded' }),
90
+ PluginsConfig({
91
+ plugins: list, selected: s.selected, loading: s.loading, error: s.error,
92
+ onSelect: (name) => ctx.set({ selected: s.selected === name ? null : name }),
93
+ onReload: load,
94
+ }),
95
+ ];
96
+ };
97
+ });
@@ -12,7 +12,8 @@ import { getRecentPaths, saveRecentPath, skillLabel, renderChatMessages } from '
12
12
  import { home, agents, analytics } from './freddie/pages-overview.js';
13
13
  import { chat, voice } from './freddie/pages-chat.js';
14
14
  import { sessions, projects, git } from './freddie/pages-workspace.js';
15
- import { models, skills, plugins, config, env } from './freddie/pages-config.js';
15
+ import { models, skills, plugins } from './freddie/pages-models.js';
16
+ import { config, env } from './freddie/pages-config.js';
16
17
  import { cron, tools, batch } from './freddie/pages-runners.js';
17
18
  import { gateway, chains, machines, health } from './freddie/pages-infra.js';
18
19
  import { logs, debug } from './freddie/pages-telemetry.js';
@@ -0,0 +1,25 @@
1
+ // Mobile-breakpoint detection — subscribe-style (webjsx has no hooks/
2
+ // useSyncExternalStore equivalent; consumers re-render on the callback).
3
+ // Builds on editor-primitives.js's BP_SM (480px) rather than pi-web's
4
+ // hardcoded 640px so it stays one source of truth with the Grid/GridItem
5
+ // breakpoint tiers already in this design system.
6
+
7
+ const MOBILE_QUERY = '(max-width: 480px)';
8
+
9
+ export function isMobileNow() {
10
+ if (typeof window === 'undefined' || !window.matchMedia) return false;
11
+ return window.matchMedia(MOBILE_QUERY).matches;
12
+ }
13
+
14
+ // Returns an unsubscribe function, mirroring theme.js's onThemeChange shape.
15
+ export function onMobileChange(cb) {
16
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {};
17
+ const mql = window.matchMedia(MOBILE_QUERY);
18
+ const handler = () => cb(mql.matches);
19
+ if (mql.addEventListener) mql.addEventListener('change', handler);
20
+ else mql.addListener(handler);
21
+ return () => {
22
+ if (mql.removeEventListener) mql.removeEventListener('change', handler);
23
+ else mql.removeListener(handler);
24
+ };
25
+ }
@@ -0,0 +1,214 @@
1
+ // Pointer-driven interaction behaviours, Pointer Events only (mouse + touch +
2
+ // pen + XR controller): useDraggable (threshold-gated data-transfer drag with a
3
+ // keyboard-only Space/Arrow fallback), useNumberScrub (drag-to-scrub a numeric
4
+ // input without losing click-to-edit), usePointerDrag (raw per-frame 2D drag
5
+ // for canvas/gizmo surfaces), and useDropTarget.
6
+
7
+ const DRAG_THRESHOLD = 5;
8
+
9
+ function dispatchDrag(el, detail) {
10
+ el.dispatchEvent(new CustomEvent('ds-drag', { detail, bubbles: true, composed: true }));
11
+ }
12
+
13
+ export function useDraggable(el, { data, kind, onDragStart, onDragEnd } = {}) {
14
+ if (!el) return { destroy() {} };
15
+ let startX = 0, startY = 0, active = false, started = false, pid = null;
16
+ let kbMode = false, kbTargets = [], kbIdx = 0;
17
+
18
+ const onMove = (e) => {
19
+ if (!active) return;
20
+ if (!started) {
21
+ const dx = e.clientX - startX, dy = e.clientY - startY;
22
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
23
+ started = true;
24
+ el.setAttribute('data-dragging', 'true');
25
+ if (onDragStart) onDragStart({ data, kind, pointerEvent: e });
26
+ }
27
+ const hit = document.elementFromPoint(e.clientX, e.clientY);
28
+ const target = hit && hit.closest('[data-drop-target]');
29
+ dispatchDrag(el, { phase: 'move', data, kind, pointerEvent: e, target });
30
+ };
31
+ const onUp = (e) => {
32
+ if (!active) return;
33
+ const wasStarted = started;
34
+ active = false; started = false;
35
+ try { if (pid != null) el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
36
+ pid = null;
37
+ el.removeAttribute('data-dragging');
38
+ const hit = document.elementFromPoint(e.clientX, e.clientY);
39
+ const target = hit && hit.closest('[data-drop-target]');
40
+ dispatchDrag(el, { phase: 'end', data, kind, pointerEvent: e, target });
41
+ window.removeEventListener('pointermove', onMove);
42
+ window.removeEventListener('pointerup', onUp);
43
+ window.removeEventListener('pointercancel', onUp);
44
+ if (wasStarted && onDragEnd) onDragEnd({ drop: target, data, kind });
45
+ };
46
+ const onDown = (e) => {
47
+ if (e.button != null && e.button !== 0) return;
48
+ active = true; started = false;
49
+ startX = e.clientX; startY = e.clientY; pid = e.pointerId;
50
+ try { el.setPointerCapture(e.pointerId); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
51
+ window.addEventListener('pointermove', onMove);
52
+ window.addEventListener('pointerup', onUp);
53
+ window.addEventListener('pointercancel', onUp);
54
+ };
55
+ const clearActive = () => kbTargets.forEach(n => n.removeAttribute('data-drop-target-active'));
56
+ const onKey = (e) => {
57
+ if (e.key === ' ' || e.code === 'Space') {
58
+ e.preventDefault();
59
+ if (!kbMode) {
60
+ kbMode = true; kbIdx = 0;
61
+ kbTargets = Array.from(document.querySelectorAll('[data-drop-target]'));
62
+ el.setAttribute('data-dragging', 'true');
63
+ if (onDragStart) onDragStart({ data, kind, pointerEvent: null });
64
+ if (kbTargets[0]) kbTargets[0].setAttribute('data-drop-target-active', 'true');
65
+ } else {
66
+ const t = kbTargets[kbIdx];
67
+ kbMode = false; el.removeAttribute('data-dragging'); clearActive();
68
+ if (t) dispatchDrag(el, { phase: 'end', data, kind, pointerEvent: null, target: t });
69
+ if (onDragEnd) onDragEnd({ drop: t, data, kind });
70
+ }
71
+ } else if (kbMode && /^Arrow/.test(e.key)) {
72
+ e.preventDefault();
73
+ const dir = (e.key === 'ArrowDown' || e.key === 'ArrowRight') ? 1 : -1;
74
+ clearActive();
75
+ kbIdx = (kbIdx + dir + kbTargets.length) % kbTargets.length;
76
+ if (kbTargets[kbIdx]) kbTargets[kbIdx].setAttribute('data-drop-target-active', 'true');
77
+ } else if (kbMode && e.key === 'Escape') {
78
+ e.preventDefault(); kbMode = false; el.removeAttribute('data-dragging'); clearActive();
79
+ }
80
+ };
81
+ el.addEventListener('pointerdown', onDown);
82
+ el.addEventListener('keydown', onKey);
83
+ return { destroy() {
84
+ el.removeEventListener('pointerdown', onDown);
85
+ el.removeEventListener('keydown', onKey);
86
+ window.removeEventListener('pointermove', onMove);
87
+ window.removeEventListener('pointerup', onUp);
88
+ window.removeEventListener('pointercancel', onUp);
89
+ }};
90
+ }
91
+
92
+ // useNumberScrub — pointer-event horizontal drag-to-scrub on a numeric input.
93
+ // Pointer Events (mouse+touch+pen+XR), touch-action:none so a vertical page
94
+ // scroll never steals the gesture. Click-to-edit is preserved: a press that
95
+ // does not cross SCRUB_THRESHOLD leaves the input focusable for typing.
96
+ export function useNumberScrub(el, { getValue, onChange, step = 0.01, threshold = 3 } = {}) {
97
+ if (!el) return { destroy() {} };
98
+ el.style.touchAction = 'none';
99
+ let pid = null, startX = 0, startV = 0, moved = false;
100
+ const onMove = (e) => {
101
+ if (pid == null) return;
102
+ const dx = e.clientX - startX;
103
+ if (!moved) {
104
+ if (Math.abs(dx) < threshold) return;
105
+ moved = true;
106
+ el.setAttribute('data-scrubbing', 'true');
107
+ if (document.activeElement === el) el.blur();
108
+ }
109
+ const v = startV + dx * step;
110
+ if (onChange) onChange(v);
111
+ };
112
+ const onUp = () => {
113
+ if (pid == null) return;
114
+ try { el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
115
+ pid = null;
116
+ el.removeAttribute('data-scrubbing');
117
+ window.removeEventListener('pointermove', onMove);
118
+ window.removeEventListener('pointerup', onUp);
119
+ window.removeEventListener('pointercancel', onUp);
120
+ };
121
+ const onDown = (e) => {
122
+ if (e.button != null && e.button !== 0) return;
123
+ // Let a focused input handle caret placement / text selection instead.
124
+ if (document.activeElement === el) return;
125
+ pid = e.pointerId; startX = e.clientX; moved = false;
126
+ const cur = getValue ? getValue() : parseFloat(el.value);
127
+ startV = Number.isFinite(cur) ? cur : 0;
128
+ try { el.setPointerCapture(pid); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
129
+ window.addEventListener('pointermove', onMove);
130
+ window.addEventListener('pointerup', onUp);
131
+ window.addEventListener('pointercancel', onUp);
132
+ };
133
+ el.addEventListener('pointerdown', onDown);
134
+ return { destroy() {
135
+ el.removeEventListener('pointerdown', onDown);
136
+ window.removeEventListener('pointermove', onMove);
137
+ window.removeEventListener('pointerup', onUp);
138
+ window.removeEventListener('pointercancel', onUp);
139
+ }};
140
+ }
141
+
142
+ // usePointerDrag — free 2D pointer drag with caller-supplied onMove, for surfaces
143
+ // that need raw pointer coordinates each frame (a 3D viewport gizmo, a canvas
144
+ // handle) rather than the data-transfer DnD model of useDraggable. Pointer Events
145
+ // only, so mouse+touch+pen+XR-controller all drive it. The primary pointer is
146
+ // captured on the element (drag keeps tracking when it leaves the element or
147
+ // crosses a panel) and released on pointerup/pointercancel; a non-primary pointer
148
+ // (second finger) is ignored mid-drag so multi-touch never makes the drag jump.
149
+ // onStart returns false to decline the drag (e.g. a miss in the gizmo raycast),
150
+ // leaving the pointerdown to propagate to other handlers.
151
+ export function usePointerDrag(el, { onStart, onMove, onEnd, button = 0 } = {}) {
152
+ if (!el) return { destroy() {} };
153
+ let pid = null;
154
+ const onMoveEv = (e) => {
155
+ if (pid == null || e.pointerId !== pid) return;
156
+ if (onMove) onMove(e);
157
+ };
158
+ const finish = (e, cancelled) => {
159
+ if (pid == null) return;
160
+ try { el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
161
+ pid = null;
162
+ el.removeAttribute('data-pointer-dragging');
163
+ window.removeEventListener('pointermove', onMoveEv);
164
+ window.removeEventListener('pointerup', onUpEv);
165
+ window.removeEventListener('pointercancel', onCancelEv);
166
+ if (onEnd) onEnd(e, cancelled);
167
+ };
168
+ const onUpEv = (e) => { if (e.pointerId === pid) finish(e, false); };
169
+ const onCancelEv = (e) => { if (e.pointerId === pid) finish(e, true); };
170
+ const onDown = (e) => {
171
+ if (button != null && e.button != null && e.button !== button) return;
172
+ if (pid != null) return; // already dragging with the primary pointer
173
+ if (onStart && onStart(e) === false) return; // caller declined (e.g. raycast miss)
174
+ pid = e.pointerId;
175
+ el.setAttribute('data-pointer-dragging', 'true');
176
+ try { el.setPointerCapture(pid); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
177
+ window.addEventListener('pointermove', onMoveEv);
178
+ window.addEventListener('pointerup', onUpEv);
179
+ window.addEventListener('pointercancel', onCancelEv);
180
+ };
181
+ el.addEventListener('pointerdown', onDown);
182
+ return { destroy() {
183
+ el.removeEventListener('pointerdown', onDown);
184
+ window.removeEventListener('pointermove', onMoveEv);
185
+ window.removeEventListener('pointerup', onUpEv);
186
+ window.removeEventListener('pointercancel', onCancelEv);
187
+ }, get dragging() { return pid != null; } };
188
+ }
189
+
190
+ export function useDropTarget(el, { accepts = [], onDrop, onDragOver } = {}) {
191
+ if (!el) return { destroy() {} };
192
+ el.setAttribute('data-drop-target', '');
193
+ const handler = (e) => {
194
+ const d = e.detail; if (!d) return;
195
+ if (accepts.length && !accepts.includes(d.kind)) return;
196
+ if (d.target !== el) {
197
+ el.removeAttribute('data-drop-target-active');
198
+ return;
199
+ }
200
+ if (d.phase === 'move') {
201
+ el.setAttribute('data-drop-target-active', 'true');
202
+ if (onDragOver) onDragOver({ data: d.data, kind: d.kind, pointerEvent: d.pointerEvent });
203
+ } else if (d.phase === 'end') {
204
+ el.removeAttribute('data-drop-target-active');
205
+ if (onDrop) onDrop({ data: d.data, kind: d.kind, pointerEvent: d.pointerEvent });
206
+ }
207
+ };
208
+ document.addEventListener('ds-drag', handler, true);
209
+ return { destroy() {
210
+ document.removeEventListener('ds-drag', handler, true);
211
+ el.removeAttribute('data-drop-target');
212
+ el.removeAttribute('data-drop-target-active');
213
+ }};
214
+ }
@@ -0,0 +1,47 @@
1
+ // Reorderable — a drag-to-reorder list composed from useDraggable +
2
+ // useDropTarget: every row is both a drag source (via its own handle) and a
3
+ // drop target carrying its index.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Icon } from '../shell.js';
7
+ import { useDraggable, useDropTarget } from './pointer.js';
8
+ const h = webjsx.createElement;
9
+
10
+ export function Reorderable({ items = [], getKey, renderItem, onReorder, axis = 'vertical', kind = 'reorder' } = {}) {
11
+ const order = items.map((_, i) => i);
12
+ const cls = 'ds-reorderable ds-reorderable-' + axis;
13
+ return h('div', { class: cls, role: 'list' },
14
+ ...items.map((item, i) => {
15
+ const key = getKey ? getKey(item, i) : i;
16
+ const onRef = (el) => {
17
+ if (!el || el._dsReorder) return;
18
+ el._dsReorder = true;
19
+ const handle = el.querySelector('.ds-reorder-handle') || el;
20
+ const drag = useDraggable(handle, {
21
+ data: { index: i }, kind,
22
+ onDragEnd: ({ drop }) => {
23
+ if (!drop) return;
24
+ const toIdx = Number(drop.getAttribute('data-reorder-index'));
25
+ if (Number.isNaN(toIdx) || toIdx === i) return;
26
+ const next = order.slice();
27
+ const [m] = next.splice(i, 1);
28
+ next.splice(toIdx, 0, m);
29
+ if (onReorder) onReorder(next);
30
+ },
31
+ });
32
+ const drop = useDropTarget(el, { accepts: [kind] });
33
+ el._dsReorderDestroy = () => { drag.destroy(); drop.destroy(); };
34
+ };
35
+ return h('div', {
36
+ key, ref: onRef, class: 'ds-reorder-item',
37
+ 'data-reorder-index': String(i), role: 'listitem',
38
+ },
39
+ h('button', {
40
+ type: 'button', class: 'ds-reorder-handle',
41
+ 'aria-label': 'Reorder', tabindex: '0',
42
+ }, Icon('more-horizontal')),
43
+ renderItem ? renderItem(item, i) : null
44
+ );
45
+ })
46
+ );
47
+ }
@@ -0,0 +1,119 @@
1
+ // Keyboard shortcuts: combo parsing/matching (platform-aware Mod = Cmd on mac,
2
+ // Ctrl elsewhere), the binding registrar that also feeds a global registry, and
3
+ // the three display surfaces — ShortcutHint, ShortcutList, ShortcutHelpDialog.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ const h = webjsx.createElement;
7
+
8
+ const IS_MAC = (typeof navigator !== 'undefined') && /Mac|iPhone|iPad/.test(navigator.platform || '');
9
+ const SHORTCUT_REGISTRY = new Set();
10
+
11
+ function parseCombo(combo) {
12
+ const parts = combo.split('+').map(s => s.trim());
13
+ const key = parts.pop();
14
+ const mods = new Set(parts.map(s => s.toLowerCase()));
15
+ return { key: key.length === 1 ? key.toLowerCase() : key, mod: mods.has('mod'), shift: mods.has('shift'), alt: mods.has('alt'), ctrl: mods.has('ctrl') };
16
+ }
17
+ function matchEvent(e, spec) {
18
+ const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
19
+ if (k !== spec.key) return false;
20
+ const modOk = spec.mod ? (IS_MAC ? e.metaKey : e.ctrlKey) : true;
21
+ if (!modOk) return false;
22
+ if (spec.shift !== !!e.shiftKey) return false;
23
+ if (spec.alt !== !!e.altKey) return false;
24
+ if (spec.ctrl && !e.ctrlKey) return false;
25
+ return true;
26
+ }
27
+
28
+ export function formatShortcut(combo) {
29
+ const s = parseCombo(combo);
30
+ const mod = s.mod ? (IS_MAC ? 'Cmd+' : 'Ctrl+') : '';
31
+ const shift = s.shift ? 'Shift+' : '';
32
+ const alt = s.alt ? 'Alt+' : '';
33
+ const key = s.key.length === 1 ? s.key.toUpperCase() : s.key;
34
+ return mod + alt + shift + key;
35
+ }
36
+
37
+ export function useKeyboardShortcut(map = {}, { scope = 'global', enabled = true } = {}) {
38
+ if (!enabled) return { destroy() {}, trigger() {} };
39
+ const target = scope === 'global' ? (typeof document !== 'undefined' ? document : null) : scope;
40
+ if (!target) return { destroy() {}, trigger() {} };
41
+ const specs = Object.entries(map).map(([combo, fn]) => ({ combo, spec: parseCombo(combo), fn }));
42
+ specs.forEach(s => SHORTCUT_REGISTRY.add({ combo: s.combo, scope: scope === 'global' ? 'global' : 'local' }));
43
+ const onKey = (e) => {
44
+ for (const s of specs) if (matchEvent(e, s.spec)) { e.preventDefault(); s.fn(e); return; }
45
+ };
46
+ target.addEventListener('keydown', onKey);
47
+ return {
48
+ destroy() { target.removeEventListener('keydown', onKey); },
49
+ trigger(combo) { const s = specs.find(x => x.combo === combo); if (s) s.fn(); },
50
+ };
51
+ }
52
+
53
+ export function ShortcutHint({ combo, kind = 'kbd' } = {}) { return h('kbd', { class: 'ds-kbd ds-kbd-' + kind }, formatShortcut(combo || '')); }
54
+
55
+ function shortcutCaps(keys) {
56
+ const caps = [];
57
+ let n = 0;
58
+ const alts = String(keys || '').split(' / ');
59
+ alts.forEach((alt, ai) => {
60
+ if (ai > 0) caps.push(h('span', { key: 'sep-alt-' + (n++), class: 'ds-kbd-sep' }, ' / '));
61
+ const steps = alt.split(' then ');
62
+ steps.forEach((step, si) => {
63
+ if (si > 0) caps.push(h('span', { key: 'sep-then-' + (n++), class: 'ds-kbd-sep' }, ' then '));
64
+ caps.push(h('kbd', { key: 'cap-' + (n++), class: 'ds-kbd' }, step));
65
+ });
66
+ });
67
+ return caps;
68
+ }
69
+
70
+ export function ShortcutList({ shortcuts = [] } = {}) {
71
+ return h('div', { class: 'ds-shortcuts-hint' },
72
+ ...shortcuts.map(s => h('div', { class: 'ds-shortcut-row' },
73
+ h('span', { class: 'ds-kbd-caps' }, ...shortcutCaps(s.keys || s.combo || '')),
74
+ h('span', { class: 'ds-kbd-label' }, s.desc || s.description || s.label || ''))));
75
+ }
76
+
77
+ export function useKeyboardShortcutHelp() { return { registry: Array.from(SHORTCUT_REGISTRY) }; }
78
+ export function ShortcutHelpDialog({ open = false, onClose, registry } = {}) {
79
+ if (!open) return null;
80
+ const list = registry || Array.from(SHORTCUT_REGISTRY);
81
+ const groups = {};
82
+ list.forEach(r => { (groups[r.scope] = groups[r.scope] || []).push(r); });
83
+ // Escape-to-close, Tab focus trap, and autofocus on open — wired through a
84
+ // ref so teardown runs on the webjsx ref(null) unmount branch.
85
+ const dialogRef = (el) => {
86
+ if (!el) { if (ShortcutHelpDialog._teardown) { ShortcutHelpDialog._teardown(); ShortcutHelpDialog._teardown = null; } return; }
87
+ const focusables = () => el.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
88
+ const onKey = (e) => {
89
+ if (e.key === 'Escape') { e.preventDefault(); if (onClose) onClose(); return; }
90
+ if (e.key === 'Tab') {
91
+ const f = focusables();
92
+ if (!f.length) { e.preventDefault(); return; }
93
+ const first = f[0], last = f[f.length - 1];
94
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
95
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
96
+ }
97
+ };
98
+ el.addEventListener('keydown', onKey);
99
+ ShortcutHelpDialog._teardown = () => el.removeEventListener('keydown', onKey);
100
+ // The dialog itself is focusable (tabindex=-1) so it always has a home
101
+ // for focus even when it contains no interactive controls.
102
+ const f = focusables();
103
+ (f[0] || el).focus();
104
+ };
105
+ return h('div', { class: 'ds-ep-dialog-backdrop', onmousedown: (e) => { if (e.target === e.currentTarget && onClose) onClose(); } },
106
+ h('div', { class: 'ds-ep-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts', tabindex: '-1', ref: dialogRef },
107
+ h('h2', null, 'Keyboard shortcuts'),
108
+ ...Object.entries(groups).map(([scope, rows]) =>
109
+ h('section', { class: 'ds-kbd-group' },
110
+ h('h3', null, scope),
111
+ h('ul', null, ...rows.map(r => h('li', { class: 'ds-kbd-row' },
112
+ h(ShortcutHint, { combo: r.combo }),
113
+ (r.label || r.description) ? h('span', { class: 'ds-kbd-label' }, r.label || r.description) : null
114
+ )))
115
+ )
116
+ )
117
+ )
118
+ );
119
+ }