@moldable-ai/ui 0.2.8 → 0.2.10

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 (38) hide show
  1. package/dist/components/app-error-boundary.d.ts +26 -0
  2. package/dist/components/app-error-boundary.d.ts.map +1 -0
  3. package/dist/components/app-error-boundary.js +87 -0
  4. package/dist/components/chat/chat-input.d.ts +8 -1
  5. package/dist/components/chat/chat-input.d.ts.map +1 -1
  6. package/dist/components/chat/chat-input.js +6 -3
  7. package/dist/components/chat/chat-message.d.ts +1 -0
  8. package/dist/components/chat/chat-message.d.ts.map +1 -1
  9. package/dist/components/chat/chat-message.js +100 -12
  10. package/dist/components/chat/chat-messages.d.ts +2 -1
  11. package/dist/components/chat/chat-messages.d.ts.map +1 -1
  12. package/dist/components/chat/chat-messages.js +3 -3
  13. package/dist/components/chat/chat-panel.d.ts +25 -3
  14. package/dist/components/chat/chat-panel.d.ts.map +1 -1
  15. package/dist/components/chat/chat-panel.js +158 -42
  16. package/dist/components/chat/index.d.ts +3 -3
  17. package/dist/components/chat/index.d.ts.map +1 -1
  18. package/dist/components/chat/index.js +1 -1
  19. package/dist/components/chat/model-selector.d.ts.map +1 -1
  20. package/dist/components/chat/model-selector.js +1 -1
  21. package/dist/components/chat/tool-handlers.d.ts.map +1 -1
  22. package/dist/components/chat/tool-handlers.js +89 -1
  23. package/dist/components/markdown.d.ts.map +1 -1
  24. package/dist/components/markdown.js +16 -2
  25. package/dist/components/rich-media-player.d.ts +8 -0
  26. package/dist/components/rich-media-player.d.ts.map +1 -0
  27. package/dist/components/rich-media-player.js +99 -0
  28. package/dist/components/ui/resizable.d.ts +4 -1
  29. package/dist/components/ui/resizable.d.ts.map +1 -1
  30. package/dist/components/ui/resizable.js +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +3 -0
  34. package/package.json +4 -1
  35. package/src/styles/index.css +25 -0
  36. package/dist/lib/commands.test.d.ts +0 -2
  37. package/dist/lib/commands.test.d.ts.map +0 -1
  38. package/dist/lib/commands.test.js +0 -111
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { FastForward, Pause, Play, Rewind, Volume2, VolumeX, } from 'lucide-react';
5
+ import { cn } from '../lib/utils';
6
+ const PLAYBACK_RATES = [0.75, 1, 1.25, 1.5, 2];
7
+ function formatTime(value) {
8
+ if (!Number.isFinite(value) || value < 0)
9
+ return '0:00';
10
+ const totalSeconds = Math.floor(value);
11
+ const hours = Math.floor(totalSeconds / 3600);
12
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
13
+ const seconds = totalSeconds % 60;
14
+ if (hours > 0) {
15
+ return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
16
+ }
17
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
18
+ }
19
+ function safeDuration(duration) {
20
+ return Number.isFinite(duration) && duration > 0 ? duration : 0;
21
+ }
22
+ export function RichMediaPlayer({ className, kind, src, title, }) {
23
+ const mediaRef = useRef(null);
24
+ const containerRef = useRef(null);
25
+ const [currentTime, setCurrentTime] = useState(0);
26
+ const [duration, setDuration] = useState(0);
27
+ const [isPlaying, setIsPlaying] = useState(false);
28
+ const [isMuted, setIsMuted] = useState(false);
29
+ const [volume, setVolume] = useState(1);
30
+ const [playbackRate, setPlaybackRate] = useState(1);
31
+ const durationValue = safeDuration(duration);
32
+ const canSeek = durationValue > 0;
33
+ const isAudio = kind === 'audio';
34
+ const mediaTitle = useMemo(() => {
35
+ if (title)
36
+ return title;
37
+ try {
38
+ return decodeURIComponent(src.split('/').pop()?.split('?')[0] ?? src);
39
+ }
40
+ catch {
41
+ return src;
42
+ }
43
+ }, [src, title]);
44
+ const setMediaElement = useCallback((element) => {
45
+ mediaRef.current = element;
46
+ }, []);
47
+ const togglePlayback = () => {
48
+ const media = mediaRef.current;
49
+ if (!media)
50
+ return;
51
+ if (media.paused) {
52
+ void media.play();
53
+ }
54
+ else {
55
+ media.pause();
56
+ }
57
+ };
58
+ const seekBy = (offset) => {
59
+ const media = mediaRef.current;
60
+ if (!media)
61
+ return;
62
+ media.currentTime = Math.max(0, Math.min(durationValue, media.currentTime + offset));
63
+ };
64
+ const seekTo = (value) => {
65
+ const media = mediaRef.current;
66
+ if (!media)
67
+ return;
68
+ media.currentTime = value;
69
+ setCurrentTime(value);
70
+ };
71
+ const changeVolume = (value) => {
72
+ const media = mediaRef.current;
73
+ if (!media)
74
+ return;
75
+ media.volume = value;
76
+ media.muted = value === 0;
77
+ setVolume(value);
78
+ setIsMuted(value === 0);
79
+ };
80
+ const toggleMuted = () => {
81
+ const media = mediaRef.current;
82
+ if (!media)
83
+ return;
84
+ const nextMuted = !media.muted;
85
+ media.muted = nextMuted;
86
+ setIsMuted(nextMuted);
87
+ };
88
+ const cyclePlaybackRate = () => {
89
+ const media = mediaRef.current;
90
+ if (!media)
91
+ return;
92
+ const currentIndex = PLAYBACK_RATES.findIndex((rate) => rate === playbackRate);
93
+ const nextRate = PLAYBACK_RATES[(currentIndex + 1) % PLAYBACK_RATES.length] ?? 1;
94
+ media.playbackRate = nextRate;
95
+ setPlaybackRate(nextRate);
96
+ };
97
+ const controls = (_jsxs("div", { className: cn('flex flex-col gap-2 text-white', isAudio ? 'p-3' : 'bg-gradient-to-b from-transparent via-black/65 to-neutral-950 px-3 pt-10 pb-3'), children: [_jsx("input", { "aria-label": "Seek", className: "h-2 w-full cursor-pointer accent-white disabled:cursor-default disabled:opacity-50", disabled: !canSeek, max: durationValue, min: 0, onChange: (event) => seekTo(Number(event.currentTarget.value)), step: 0.1, type: "range", value: Math.min(currentTime, durationValue) }), _jsxs("div", { className: "flex h-10 items-center gap-3 px-1", children: [_jsx("button", { "aria-label": isPlaying ? 'Pause' : 'Play', className: "cursor-pointer rounded-full p-1 text-white transition-colors hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white", onClick: togglePlayback, type: "button", children: isPlaying ? _jsx(Pause, { className: "size-6" }) : _jsx(Play, { className: "size-6" }) }), _jsx("button", { "aria-label": "Rewind 10 seconds", className: "cursor-pointer rounded-full p-1 text-white/85 transition-colors hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white", onClick: () => seekBy(-10), type: "button", children: _jsx(Rewind, { className: "size-5" }) }), _jsx("button", { "aria-label": "Forward 10 seconds", className: "cursor-pointer rounded-full p-1 text-white/85 transition-colors hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white", onClick: () => seekBy(10), type: "button", children: _jsx(FastForward, { className: "size-5" }) }), _jsxs("div", { className: "group/volume flex items-center gap-2", children: [_jsx("button", { "aria-label": isMuted ? 'Unmute' : 'Mute', className: "cursor-pointer rounded-full p-1 text-white/85 transition-colors hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white", onClick: toggleMuted, type: "button", children: isMuted || volume === 0 ? (_jsx(VolumeX, { className: "size-5" })) : (_jsx(Volume2, { className: "size-5" })) }), _jsx("input", { "aria-label": "Volume", className: "h-2 w-0 cursor-pointer accent-white opacity-0 transition-all group-hover/volume:w-20 group-hover/volume:opacity-100 focus:w-20 focus:opacity-100", max: 1, min: 0, onChange: (event) => changeVolume(Number(event.currentTarget.value)), step: 0.05, type: "range", value: isMuted ? 0 : volume })] }), _jsxs("span", { className: "ml-auto min-w-28 text-right font-mono text-xs text-white/80", children: [formatTime(currentTime), " / ", formatTime(durationValue)] }), _jsxs("button", { "aria-label": "Change playback speed", className: "cursor-pointer rounded px-2 py-1 font-mono text-xs text-white/85 transition-colors hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white", onClick: cyclePlaybackRate, type: "button", children: [playbackRate, "x"] })] })] }));
98
+ return (_jsx("div", { className: cn('not-prose overflow-hidden rounded-lg border border-black/5 bg-neutral-950 text-white shadow-sm dark:border-white/10', className), ref: containerRef, children: _jsxs("div", { className: cn('group/mediaplayer relative w-full overflow-hidden', !isAudio && 'bg-black'), children: [isAudio ? (_jsx("audio", { "aria-label": mediaTitle, onDurationChange: (event) => setDuration(event.currentTarget.duration), onEnded: () => setIsPlaying(false), onLoadedMetadata: (event) => setDuration(event.currentTarget.duration), onPause: () => setIsPlaying(false), onPlay: () => setIsPlaying(true), onTimeUpdate: (event) => setCurrentTime(event.currentTarget.currentTime), preload: "metadata", ref: setMediaElement, src: src })) : (_jsx("video", { className: "h-auto max-h-[600px] min-h-[225px] w-full bg-black object-contain", onClick: togglePlayback, onDurationChange: (event) => setDuration(event.currentTarget.duration), onEnded: () => setIsPlaying(false), onLoadedMetadata: (event) => setDuration(event.currentTarget.duration), onPause: () => setIsPlaying(false), onPlay: () => setIsPlaying(true), onTimeUpdate: (event) => setCurrentTime(event.currentTarget.currentTime), playsInline: true, preload: "metadata", ref: setMediaElement, src: src })), _jsx("div", { className: cn(isAudio ? 'bg-neutral-950' : 'absolute right-0 bottom-0 left-0 transition-transform duration-300', !isAudio && isPlaying && 'translate-y-[calc(100%-36px)] group-hover/mediaplayer:translate-y-0'), children: controls })] }) }));
99
+ }
@@ -1,6 +1,9 @@
1
1
  import { type GroupProps, type PanelProps, type SeparatorProps } from 'react-resizable-panels';
2
2
  declare function ResizablePanelGroup({ className, ...props }: GroupProps): import("react/jsx-runtime").JSX.Element;
3
- declare function ResizablePanel({ ...props }: PanelProps): import("react/jsx-runtime").JSX.Element;
3
+ type ResizablePanelProps = PanelProps & {
4
+ groupResizeBehavior?: string;
5
+ };
6
+ declare function ResizablePanel({ groupResizeBehavior: _groupResizeBehavior, ...props }: ResizablePanelProps): import("react/jsx-runtime").JSX.Element;
4
7
  declare function ResizableHandle({ withHandle, className, ...props }: SeparatorProps & {
5
8
  withHandle?: boolean;
6
9
  }): import("react/jsx-runtime").JSX.Element;
@@ -1 +1 @@
1
- {"version":3,"file":"resizable.d.ts","sourceRoot":"","sources":["../../../src/components/ui/resizable.tsx"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,UAAU,EAEf,KAAK,UAAU,EAEf,KAAK,cAAc,EACpB,MAAM,wBAAwB,CAAA;AAG/B,iBAAS,mBAAmB,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,UAAU,2CAW/D;AAED,iBAAS,cAAc,CAAC,EAAE,GAAG,KAAK,EAAE,EAAE,UAAU,2CAE/C;AAED,iBAAS,eAAe,CAAC,EACvB,UAAU,EACV,SAAS,EACT,GAAG,KAAK,EACT,EAAE,cAAc,GAAG;IAClB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB,2CAiBA;AAED,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,eAAe,EAAE,CAAA"}
1
+ {"version":3,"file":"resizable.d.ts","sourceRoot":"","sources":["../../../src/components/ui/resizable.tsx"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,UAAU,EAEf,KAAK,UAAU,EAEf,KAAK,cAAc,EACpB,MAAM,wBAAwB,CAAA;AAG/B,iBAAS,mBAAmB,CAAC,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,UAAU,2CAW/D;AAED,KAAK,mBAAmB,GAAG,UAAU,GAAG;IACtC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAED,iBAAS,cAAc,CAAC,EACtB,mBAAmB,EAAE,oBAAoB,EACzC,GAAG,KAAK,EACT,EAAE,mBAAmB,2CAErB;AAED,iBAAS,eAAe,CAAC,EACvB,UAAU,EACV,SAAS,EACT,GAAG,KAAK,EACT,EAAE,cAAc,GAAG;IAClB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB,2CAiBA;AAED,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,eAAe,EAAE,CAAA"}
@@ -6,7 +6,7 @@ import { cn } from '../../lib/utils';
6
6
  function ResizablePanelGroup({ className, ...props }) {
7
7
  return (_jsx(Group, { "data-slot": "resizable-panel-group", className: cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className), ...props }));
8
8
  }
9
- function ResizablePanel({ ...props }) {
9
+ function ResizablePanel({ groupResizeBehavior: _groupResizeBehavior, ...props }) {
10
10
  return _jsx(Panel, { "data-slot": "resizable-panel", ...props });
11
11
  }
12
12
  function ResizableHandle({ withHandle, className, ...props }) {
package/dist/index.d.ts CHANGED
@@ -5,7 +5,9 @@ export { useMoldableCommands, useMoldableCommand, useMoldableAppCommands, isInMo
5
5
  export * from './components/ui';
6
6
  export { useIsMobile } from './hooks/use-mobile';
7
7
  export { Markdown } from './components/markdown';
8
+ export { RichMediaPlayer, type RichMediaPlayerProps, } from './components/rich-media-player';
8
9
  export { CodeBlock } from './components/code-block';
9
10
  export { WidgetLayout } from './components/widget-layout';
11
+ export { AppErrorBoundary } from './components/app-error-boundary';
10
12
  export * from './components/chat';
11
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAGhC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,KAAK,EAAE,MAAM,aAAa,CAAA;AAG9E,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,GACjB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAA;AAGvB,cAAc,iBAAiB,CAAA;AAG/B,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGhD,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAA;AAGhD,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AAGnD,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AAGzD,cAAc,mBAAmB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAGhC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,KAAK,EAAE,MAAM,aAAa,CAAA;AAG9E,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,GACjB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAA;AAGvB,cAAc,iBAAiB,CAAA;AAG/B,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGhD,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAA;AAChD,OAAO,EACL,eAAe,EACf,KAAK,oBAAoB,GAC1B,MAAM,gCAAgC,CAAA;AAGvC,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AAGnD,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAA;AAGzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAA;AAGlE,cAAc,mBAAmB,CAAA"}
package/dist/index.js CHANGED
@@ -12,9 +12,12 @@ export * from './components/ui';
12
12
  export { useIsMobile } from './hooks/use-mobile';
13
13
  // Export Markdown
14
14
  export { Markdown } from './components/markdown';
15
+ export { RichMediaPlayer, } from './components/rich-media-player';
15
16
  // Export CodeBlock
16
17
  export { CodeBlock } from './components/code-block';
17
18
  // Export WidgetLayout
18
19
  export { WidgetLayout } from './components/widget-layout';
20
+ // Export app error boundary
21
+ export { AppErrorBoundary } from './components/app-error-boundary';
19
22
  // Export chat components
20
23
  export * from './components/chat';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moldable-ai/ui",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Shared UI components for Moldable applications",
5
5
  "author": "Desiderata LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -43,6 +43,9 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@base-ui/react": "^1.1.0",
46
+ "@dnd-kit/core": "^6.3.1",
47
+ "@dnd-kit/sortable": "^10.0.0",
48
+ "@dnd-kit/utilities": "^3.2.2",
46
49
  "@hookform/resolvers": "^5.2.2",
47
50
  "@radix-ui/react-accordion": "^1.2.12",
48
51
  "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -68,6 +68,31 @@
68
68
  --chat-safe-padding: var(--chat-safe-padding);
69
69
  }
70
70
 
71
+ @keyframes moldable-chat-shimmer {
72
+ 0% {
73
+ background-position: 140% 50%;
74
+ }
75
+ 100% {
76
+ background-position: -40% 50%;
77
+ }
78
+ }
79
+
80
+ .moldable-chat-shimmer {
81
+ color: transparent;
82
+ background: linear-gradient(
83
+ 90deg,
84
+ color-mix(in oklch, var(--muted-foreground) 72%, transparent) 0%,
85
+ color-mix(in oklch, var(--foreground) 92%, white 8%) 42%,
86
+ color-mix(in oklch, var(--foreground) 100%, white 16%) 50%,
87
+ color-mix(in oklch, var(--foreground) 92%, white 8%) 58%,
88
+ color-mix(in oklch, var(--muted-foreground) 72%, transparent) 100%
89
+ );
90
+ background-size: 240% 100%;
91
+ -webkit-background-clip: text;
92
+ background-clip: text;
93
+ animation: moldable-chat-shimmer 1.8s ease-in-out infinite;
94
+ }
95
+
71
96
  :root {
72
97
  --radius: 0.65rem;
73
98
  --background: oklch(1 0 0);
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=commands.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"commands.test.d.ts","sourceRoot":"","sources":["../../src/lib/commands.test.ts"],"names":[],"mappings":""}
@@ -1,111 +0,0 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
2
- // Re-implement isInMoldable for testing (since it accesses window)
3
- function isInMoldable() {
4
- if (typeof window === 'undefined')
5
- return false;
6
- return window.parent !== window;
7
- }
8
- describe('commands utilities', () => {
9
- describe('isInMoldable', () => {
10
- const originalWindow = global.window;
11
- afterEach(() => {
12
- // Restore original window
13
- if (originalWindow) {
14
- global.window = originalWindow;
15
- }
16
- });
17
- it('returns false when window is undefined (SSR)', () => {
18
- // @ts-expect-error - intentionally setting to undefined
19
- global.window = undefined;
20
- // Re-evaluate with undefined window
21
- const result = typeof window === 'undefined' ? false : window.parent !== window;
22
- expect(result).toBe(false);
23
- });
24
- it('returns false when parent equals window (not in iframe)', () => {
25
- // jsdom default: window.parent === window
26
- expect(isInMoldable()).toBe(false);
27
- });
28
- it('returns true when parent differs from window (in iframe)', () => {
29
- // Mock the iframe scenario
30
- const mockParent = {};
31
- const originalParent = window.parent;
32
- Object.defineProperty(window, 'parent', {
33
- value: mockParent,
34
- writable: true,
35
- configurable: true,
36
- });
37
- expect(isInMoldable()).toBe(true);
38
- // Restore
39
- Object.defineProperty(window, 'parent', {
40
- value: originalParent,
41
- writable: true,
42
- configurable: true,
43
- });
44
- });
45
- });
46
- describe('CommandAction types', () => {
47
- it('navigate action has path', () => {
48
- const action = { type: 'navigate', path: '/settings' };
49
- expect(action.type).toBe('navigate');
50
- expect(action.path).toBe('/settings');
51
- });
52
- it('message action has payload', () => {
53
- const action = { type: 'message', payload: { foo: 'bar' } };
54
- expect(action.type).toBe('message');
55
- expect(action.payload).toEqual({ foo: 'bar' });
56
- });
57
- it('focus action has target', () => {
58
- const action = { type: 'focus', target: 'input-field' };
59
- expect(action.type).toBe('focus');
60
- expect(action.target).toBe('input-field');
61
- });
62
- });
63
- describe('AppCommand structure', () => {
64
- it('accepts valid command definition', () => {
65
- const command = {
66
- id: 'add-todo',
67
- label: 'Add Todo',
68
- shortcut: 'a',
69
- icon: '➕',
70
- group: 'Tasks',
71
- action: { type: 'message', payload: {} },
72
- };
73
- expect(command.id).toBe('add-todo');
74
- expect(command.label).toBe('Add Todo');
75
- expect(command.shortcut).toBe('a');
76
- expect(command.icon).toBe('➕');
77
- expect(command.group).toBe('Tasks');
78
- });
79
- it('works with minimal required fields', () => {
80
- const command = {
81
- id: 'test',
82
- label: 'Test Command',
83
- action: { type: 'navigate', path: '/' },
84
- };
85
- expect(command.id).toBeDefined();
86
- expect(command.label).toBeDefined();
87
- expect(command.action).toBeDefined();
88
- });
89
- });
90
- describe('CommandMessage structure', () => {
91
- it('has correct type marker', () => {
92
- const message = {
93
- type: 'moldable:command',
94
- command: 'add-todo',
95
- payload: { text: 'Buy milk' },
96
- };
97
- expect(message.type).toBe('moldable:command');
98
- expect(message.command).toBe('add-todo');
99
- expect(message.payload).toEqual({ text: 'Buy milk' });
100
- });
101
- it('works without payload', () => {
102
- const message = {
103
- type: 'moldable:command',
104
- command: 'refresh',
105
- };
106
- expect(message.type).toBe('moldable:command');
107
- expect(message.command).toBe('refresh');
108
- expect(message.payload).toBeUndefined();
109
- });
110
- });
111
- });