@weasel-js/labkit 0.1.0

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 (217) hide show
  1. package/README.md +72 -0
  2. package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
  3. package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
  4. package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
  5. package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
  6. package/dist/canvas/index.d.ts +34 -0
  7. package/dist/canvas/index.js +4 -0
  8. package/dist/canvas/index.js.map +1 -0
  9. package/dist/chunk-2QNYYL3V.js +86 -0
  10. package/dist/chunk-2QNYYL3V.js.map +1 -0
  11. package/dist/chunk-54IQ2DX7.js +222 -0
  12. package/dist/chunk-54IQ2DX7.js.map +1 -0
  13. package/dist/chunk-6FO7G5BB.js +6095 -0
  14. package/dist/chunk-6FO7G5BB.js.map +1 -0
  15. package/dist/chunk-7BKDG73Z.js +54 -0
  16. package/dist/chunk-7BKDG73Z.js.map +1 -0
  17. package/dist/chunk-B5ZUYRKN.js +23330 -0
  18. package/dist/chunk-B5ZUYRKN.js.map +1 -0
  19. package/dist/chunk-HXZHVU4G.js +126 -0
  20. package/dist/chunk-HXZHVU4G.js.map +1 -0
  21. package/dist/chunk-PWC7AQZM.js +8 -0
  22. package/dist/chunk-PWC7AQZM.js.map +1 -0
  23. package/dist/chunk-SD3HDXR7.js +161 -0
  24. package/dist/chunk-SD3HDXR7.js.map +1 -0
  25. package/dist/chunk-SSMHPMDV.js +350 -0
  26. package/dist/chunk-SSMHPMDV.js.map +1 -0
  27. package/dist/chunk-VLAHRJOC.js +201 -0
  28. package/dist/chunk-VLAHRJOC.js.map +1 -0
  29. package/dist/controls/index.d.ts +13 -0
  30. package/dist/controls/index.js +3 -0
  31. package/dist/controls/index.js.map +1 -0
  32. package/dist/dragdrop/index.d.ts +3 -0
  33. package/dist/dragdrop/index.js +3 -0
  34. package/dist/dragdrop/index.js.map +1 -0
  35. package/dist/fonts/oswald-latin-variable.woff2 +0 -0
  36. package/dist/index.d.ts +410 -0
  37. package/dist/index.js +1425 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/layers/index.d.ts +17 -0
  40. package/dist/layers/index.js +3 -0
  41. package/dist/layers/index.js.map +1 -0
  42. package/dist/passthrough/weasel-canvas.d.ts +258 -0
  43. package/dist/passthrough/weasel-canvas.js +3 -0
  44. package/dist/passthrough/weasel-canvas.js.map +1 -0
  45. package/dist/passthrough/weasel-ui.d.ts +2826 -0
  46. package/dist/passthrough/weasel-ui.js +4 -0
  47. package/dist/passthrough/weasel-ui.js.map +1 -0
  48. package/dist/primitives/index.d.ts +57 -0
  49. package/dist/primitives/index.js +4 -0
  50. package/dist/primitives/index.js.map +1 -0
  51. package/dist/state/index.d.ts +33 -0
  52. package/dist/state/index.js +34 -0
  53. package/dist/state/index.js.map +1 -0
  54. package/dist/styles.css +1224 -0
  55. package/dist/theme-interstellar.css +25 -0
  56. package/dist/theme-light.css +13 -0
  57. package/dist/ui/layers/index.d.ts +42 -0
  58. package/dist/ui/layers/index.js +5 -0
  59. package/dist/ui/layers/index.js.map +1 -0
  60. package/dist/undo/index.d.ts +30 -0
  61. package/dist/undo/index.js +3 -0
  62. package/dist/undo/index.js.map +1 -0
  63. package/package.json +99 -0
  64. package/src/canvas/AGENTS.md +72 -0
  65. package/src/canvas/CanvasStack.less +22 -0
  66. package/src/canvas/CanvasStack.stories.tsx +63 -0
  67. package/src/canvas/CanvasStack.test.tsx +82 -0
  68. package/src/canvas/CanvasStack.tsx +108 -0
  69. package/src/canvas/CanvasStackContext.ts +8 -0
  70. package/src/canvas/canvasCoords.test.ts +30 -0
  71. package/src/canvas/canvasCoords.ts +15 -0
  72. package/src/canvas/index.ts +6 -0
  73. package/src/canvas/useLayerScheduler.ts +72 -0
  74. package/src/canvas/usePanZoom.ts +98 -0
  75. package/src/controls/ControlPanel.less +61 -0
  76. package/src/controls/ControlPanel.stories.tsx +77 -0
  77. package/src/controls/ControlPanel.test.tsx +149 -0
  78. package/src/controls/ControlPanel.tsx +249 -0
  79. package/src/controls/index.ts +12 -0
  80. package/src/controls/types.ts +61 -0
  81. package/src/dragdrop/DragDropRuntime.tsx +135 -0
  82. package/src/dragdrop/DragGhost.less +17 -0
  83. package/src/dragdrop/DragGhost.tsx +24 -0
  84. package/src/dragdrop/Palette.less +34 -0
  85. package/src/dragdrop/Palette.tsx +27 -0
  86. package/src/dragdrop/dragDrop.test.tsx +145 -0
  87. package/src/dragdrop/index.ts +1 -0
  88. package/src/fonts/oswald-latin-variable.woff2 +0 -0
  89. package/src/index.ts +70 -0
  90. package/src/instrument/SineWave.smoke.test.tsx +94 -0
  91. package/src/instrument/capabilityDetector.test.ts +64 -0
  92. package/src/instrument/capabilityDetector.ts +17 -0
  93. package/src/instrument/defineInstrument.test.ts +27 -0
  94. package/src/instrument/defineInstrument.ts +5 -0
  95. package/src/instrument/index.ts +19 -0
  96. package/src/instrument/types.ts +65 -0
  97. package/src/instrument/validateConfigSchema.test.ts +125 -0
  98. package/src/instrument/validateConfigSchema.ts +65 -0
  99. package/src/lab/Lab.less +3 -0
  100. package/src/lab/Lab.stories.tsx +51 -0
  101. package/src/lab/Lab.test.tsx +126 -0
  102. package/src/lab/Lab.tsx +196 -0
  103. package/src/lab/LabContext.test.ts +11 -0
  104. package/src/lab/LabContext.ts +28 -0
  105. package/src/lab/LabShell.less +45 -0
  106. package/src/lab/LabShell.stories.tsx +39 -0
  107. package/src/lab/LabShell.test.tsx +59 -0
  108. package/src/lab/LabShell.tsx +33 -0
  109. package/src/lab/WorkspaceGrid.less +8 -0
  110. package/src/lab/WorkspaceGrid.stories.tsx +61 -0
  111. package/src/lab/WorkspaceGrid.test.tsx +40 -0
  112. package/src/lab/WorkspaceGrid.tsx +20 -0
  113. package/src/lab/gridDims.test.ts +35 -0
  114. package/src/lab/gridDims.ts +11 -0
  115. package/src/lab/index.ts +10 -0
  116. package/src/layers/AGENTS.md +65 -0
  117. package/src/layers/LayerList.less +53 -0
  118. package/src/layers/LayerList.test.tsx +49 -0
  119. package/src/layers/LayerList.tsx +105 -0
  120. package/src/layers/index.ts +3 -0
  121. package/src/passthrough/weasel-canvas.test.ts +30 -0
  122. package/src/passthrough/weasel-canvas.ts +23 -0
  123. package/src/passthrough/weasel-ui.test.ts +79 -0
  124. package/src/passthrough/weasel-ui.ts +150 -0
  125. package/src/primitives/FpsMeter.less +14 -0
  126. package/src/primitives/FpsMeter.stories.tsx +14 -0
  127. package/src/primitives/FpsMeter.test.tsx +38 -0
  128. package/src/primitives/FpsMeter.tsx +36 -0
  129. package/src/primitives/ScaleIndicator.less +25 -0
  130. package/src/primitives/ScaleIndicator.stories.tsx +16 -0
  131. package/src/primitives/ScaleIndicator.test.tsx +46 -0
  132. package/src/primitives/ScaleIndicator.tsx +48 -0
  133. package/src/primitives/Sidebar.less +57 -0
  134. package/src/primitives/Sidebar.stories.tsx +38 -0
  135. package/src/primitives/Sidebar.test.tsx +74 -0
  136. package/src/primitives/Sidebar.tsx +32 -0
  137. package/src/primitives/StatusBar.less +21 -0
  138. package/src/primitives/StatusBar.stories.tsx +24 -0
  139. package/src/primitives/StatusBar.test.tsx +25 -0
  140. package/src/primitives/StatusBar.tsx +18 -0
  141. package/src/primitives/Toolbar.less +43 -0
  142. package/src/primitives/Toolbar.stories.tsx +36 -0
  143. package/src/primitives/Toolbar.test.tsx +65 -0
  144. package/src/primitives/Toolbar.tsx +44 -0
  145. package/src/primitives/fpsAverage.test.ts +21 -0
  146. package/src/primitives/fpsAverage.ts +5 -0
  147. package/src/primitives/index.ts +9 -0
  148. package/src/state/SingletonExperiment.test.tsx +101 -0
  149. package/src/state/SingletonExperiment.tsx +52 -0
  150. package/src/state/adapters.test.ts +94 -0
  151. package/src/state/adapters.ts +102 -0
  152. package/src/state/context.tsx +42 -0
  153. package/src/state/helpers.test.ts +89 -0
  154. package/src/state/helpers.ts +61 -0
  155. package/src/state/index.ts +40 -0
  156. package/src/state/store.test.ts +243 -0
  157. package/src/state/store.ts +225 -0
  158. package/src/state/types.ts +53 -0
  159. package/src/state/useExperimentState.test.tsx +102 -0
  160. package/src/state/useExperimentState.ts +28 -0
  161. package/src/styles.less +20 -0
  162. package/src/test-setup.ts +17 -0
  163. package/src/theme/Interstellar.stories.less +92 -0
  164. package/src/theme/Interstellar.stories.tsx +243 -0
  165. package/src/theme/base.less +122 -0
  166. package/src/theme/interstellar.less +34 -0
  167. package/src/theme/light.less +14 -0
  168. package/src/theme/tokens.less +85 -0
  169. package/src/ui/format.ts +19 -0
  170. package/src/ui/layers/LayerStack.less +143 -0
  171. package/src/ui/layers/LayerStack.stories.tsx +45 -0
  172. package/src/ui/layers/LayerStack.test.tsx +170 -0
  173. package/src/ui/layers/LayerStack.tsx +215 -0
  174. package/src/ui/layers/index.ts +1 -0
  175. package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
  176. package/src/ui/properties/ColorRow.stories.tsx +106 -0
  177. package/src/ui/properties/CurveField.less +48 -0
  178. package/src/ui/properties/CurveField.stories.tsx +20 -0
  179. package/src/ui/properties/CurveField.test.tsx +127 -0
  180. package/src/ui/properties/CurveField.tsx +181 -0
  181. package/src/ui/properties/EffectCard.tsx +312 -0
  182. package/src/ui/properties/Gallery.stories.tsx +178 -0
  183. package/src/ui/properties/NumberRow.stories.tsx +65 -0
  184. package/src/ui/properties/PropertyGroup.less +50 -0
  185. package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
  186. package/src/ui/properties/PropertyGroup.test.tsx +33 -0
  187. package/src/ui/properties/PropertyGroup.tsx +44 -0
  188. package/src/ui/properties/PropertyList.stories.tsx +83 -0
  189. package/src/ui/properties/PropertyPanel.less +592 -0
  190. package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
  191. package/src/ui/properties/PropertyPanel.test.tsx +193 -0
  192. package/src/ui/properties/PropertyPanel.tsx +441 -0
  193. package/src/ui/properties/PropertyRow.stories.tsx +53 -0
  194. package/src/ui/properties/SelectRow.stories.tsx +58 -0
  195. package/src/ui/properties/SliderRow.stories.tsx +74 -0
  196. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
  197. package/src/ui/properties/TextRow.stories.tsx +62 -0
  198. package/src/ui/properties/ToggleRow.stories.tsx +57 -0
  199. package/src/ui/properties/index.ts +39 -0
  200. package/src/ui/properties/storyLayouts.tsx +36 -0
  201. package/src/undo/eventBus.test.ts +49 -0
  202. package/src/undo/eventBus.ts +32 -0
  203. package/src/undo/index.ts +5 -0
  204. package/src/undo/undoStack.test.ts +70 -0
  205. package/src/undo/undoStack.ts +38 -0
  206. package/src/workspace/DefaultSidebar.tsx +26 -0
  207. package/src/workspace/DefaultStatusBar.tsx +15 -0
  208. package/src/workspace/DefaultToolbar.tsx +88 -0
  209. package/src/workspace/Workspace.less +58 -0
  210. package/src/workspace/Workspace.stories.tsx +78 -0
  211. package/src/workspace/Workspace.test.tsx +186 -0
  212. package/src/workspace/Workspace.tsx +255 -0
  213. package/src/workspace/WorkspaceChrome.tsx +150 -0
  214. package/src/workspace/index.ts +24 -0
  215. package/src/workspace/slotTypes.ts +44 -0
  216. package/src/workspace/workspaceOps.test.ts +142 -0
  217. package/src/workspace/workspaceOps.ts +78 -0
@@ -0,0 +1,145 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { useContext } from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import { useStore } from 'zustand/react';
5
+ import { defineInstrument } from '../instrument/defineInstrument';
6
+ import { Lab } from '../lab/Lab';
7
+ import { LabStoreContext } from '../state/context';
8
+
9
+ interface DropState {
10
+ items: { id: string; x: number; y: number }[];
11
+ }
12
+
13
+ const testInstrument = defineInstrument<DropState, Record<string, never>>({
14
+ name: 'TestDrop',
15
+ defaultConfig: () => ({}),
16
+ initialState: () => ({ items: [] }),
17
+ configSchema: () => [],
18
+ render: () => null,
19
+ canvas: {
20
+ initialView: { zoom: 1, pan: { x: 0, y: 0 } },
21
+ layers: [{ id: 'main', draw: () => undefined }],
22
+ },
23
+ dragDrop: {
24
+ palette: [{ id: 'a', label: 'Item A' }],
25
+ onDrop: (worldPos, item, state) => ({
26
+ items: [...state.items, { id: item.id, x: worldPos.x, y: worldPos.y }],
27
+ }),
28
+ },
29
+ undo: { snapshotOn: ['canvas.itemAdded'], maxDepth: 10 },
30
+ });
31
+
32
+ const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
33
+
34
+ function StateProbe({ onState }: { onState: (state: DropState) => void }) {
35
+ const ctx = useContext(LabStoreContext);
36
+ const ws = useStore(
37
+ ctx?.store ?? ({ getState: () => ({ workspaces: [] }) } as never),
38
+ (s) => (s as { workspaces: { state: unknown }[] }).workspaces[0],
39
+ );
40
+ if (ws) onState(ws.state as DropState);
41
+ return null;
42
+ }
43
+
44
+ // biome-ignore lint/suspicious/noExplicitAny: cross-generic instrument array
45
+ const instruments: any[] = [testInstrument];
46
+
47
+ function renderLab(probe?: (state: DropState) => void) {
48
+ return render(
49
+ <Lab instruments={instruments} defaultInstrument="TestDrop">
50
+ {probe ? <StateProbe onState={probe} /> : null}
51
+ </Lab>,
52
+ );
53
+ }
54
+
55
+ beforeEach(() => {
56
+ HTMLElement.prototype.getBoundingClientRect = function () {
57
+ if (this.classList.contains('lk-workspace__canvas-host')) {
58
+ return {
59
+ x: 100,
60
+ y: 100,
61
+ left: 100,
62
+ top: 100,
63
+ right: 500,
64
+ bottom: 500,
65
+ width: 400,
66
+ height: 400,
67
+ toJSON: () => ({}),
68
+ } as DOMRect;
69
+ }
70
+ return originalGetBoundingClientRect.call(this);
71
+ };
72
+ });
73
+
74
+ afterEach(() => {
75
+ HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
76
+ });
77
+
78
+ function pointerDown(el: Element, x: number, y: number) {
79
+ fireEvent.pointerDown(el, { clientX: x, clientY: y });
80
+ }
81
+
82
+ function windowPointerMove(x: number, y: number) {
83
+ act(() => {
84
+ window.dispatchEvent(new MouseEvent('pointermove', { clientX: x, clientY: y, bubbles: true }));
85
+ });
86
+ }
87
+
88
+ function windowPointerUp(x: number, y: number) {
89
+ act(() => {
90
+ window.dispatchEvent(new MouseEvent('pointerup', { clientX: x, clientY: y, bubbles: true }));
91
+ });
92
+ }
93
+
94
+ describe('DragDrop integration', () => {
95
+ it('renders palette item in the sidebar', () => {
96
+ renderLab();
97
+ expect(screen.getByRole('button', { name: 'Item A' })).toBeInTheDocument();
98
+ });
99
+
100
+ it('drops an item onto the canvas and appends it to state', () => {
101
+ let latest: DropState = { items: [] };
102
+ renderLab((s) => {
103
+ latest = s;
104
+ });
105
+ const palette = screen.getByRole('button', { name: 'Item A' });
106
+
107
+ act(() => {
108
+ pointerDown(palette, 0, 0);
109
+ });
110
+ windowPointerUp(250, 250);
111
+
112
+ expect(latest.items).toHaveLength(1);
113
+ expect(latest.items[0]?.id).toBe('a');
114
+ });
115
+
116
+ it('drop outside the canvas leaves state unchanged', () => {
117
+ let latest: DropState = { items: [] };
118
+ renderLab((s) => {
119
+ latest = s;
120
+ });
121
+ const palette = screen.getByRole('button', { name: 'Item A' });
122
+
123
+ act(() => {
124
+ pointerDown(palette, 0, 0);
125
+ });
126
+ windowPointerMove(10, 10);
127
+ windowPointerUp(10, 10);
128
+
129
+ expect(latest.items).toHaveLength(0);
130
+ expect(document.querySelector('.lk-drag-ghost')).toBeNull();
131
+ });
132
+
133
+ it('shows drag ghost during drag and clears on drop', () => {
134
+ renderLab();
135
+ const palette = screen.getByRole('button', { name: 'Item A' });
136
+
137
+ act(() => {
138
+ pointerDown(palette, 5, 5);
139
+ });
140
+ expect(document.querySelector('.lk-drag-ghost')).not.toBeNull();
141
+
142
+ windowPointerUp(250, 250);
143
+ expect(document.querySelector('.lk-drag-ghost')).toBeNull();
144
+ });
145
+ });
@@ -0,0 +1 @@
1
+ export type { DragDropCapability, DragFeedback, PaletteItem } from '../instrument/types';
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ export type { CanvasLayerDescriptor, CanvasStackContextValue, CanvasStackProps } from './canvas';
2
+ export { CanvasStack, CanvasStackContext, screenToWorld, worldToScreen } from './canvas';
3
+ export { ControlPanel } from './controls/ControlPanel';
4
+ export type {
5
+ CheckboxField,
6
+ ColorField,
7
+ ConfigField,
8
+ ConfigFieldBase,
9
+ ConfigFieldType,
10
+ NumberField,
11
+ SelectField,
12
+ SelectOption,
13
+ SliderField,
14
+ TextField,
15
+ } from './controls/types';
16
+ export type { CapabilityFlags } from './instrument/capabilityDetector';
17
+ export { detectCapabilities } from './instrument/capabilityDetector';
18
+ export { defineInstrument } from './instrument/defineInstrument';
19
+ export type {
20
+ CanvasCapability,
21
+ CanvasLayer,
22
+ DragDropCapability,
23
+ DragFeedback,
24
+ HitResult,
25
+ Instrument,
26
+ LayerCapability,
27
+ LayerDescriptor,
28
+ PaletteItem,
29
+ Point,
30
+ RenderContext,
31
+ SystemEvent,
32
+ UndoCapability,
33
+ ViewTransform,
34
+ } from './instrument/types';
35
+ export type { ValidationResult } from './instrument/validateConfigSchema';
36
+ export { validateConfigSchema } from './instrument/validateConfigSchema';
37
+ export * from './lab';
38
+ export type { LayerListProps } from './layers';
39
+ export { LayerList } from './layers';
40
+ export * from './primitives';
41
+ export {
42
+ createMemoryAdapter,
43
+ localStorageAdapter,
44
+ noneAdapter,
45
+ sessionStorageAdapter,
46
+ urlHashAdapter,
47
+ } from './state/adapters';
48
+ export {
49
+ LabStoreContext,
50
+ LabStoreProvider,
51
+ useLabStore,
52
+ useWorkspaceId,
53
+ WorkspaceIdContext,
54
+ WorkspaceIdProvider,
55
+ } from './state/context';
56
+ export type {
57
+ CreateLabStoreOptions,
58
+ ExperimentStateHandle,
59
+ LabStoreState,
60
+ SavedSnapshot,
61
+ StorageAdapter,
62
+ UndoStack,
63
+ WorkspaceRecord,
64
+ } from './state/types';
65
+ export { useExperimentState } from './state/useExperimentState';
66
+ export * from './ui/layers';
67
+ export * from './ui/properties';
68
+ export type { EventBus, EventListener } from './undo';
69
+ export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from './undo';
70
+ export * from './workspace';
@@ -0,0 +1,94 @@
1
+ import { render } from '@testing-library/react';
2
+ import type { ReactElement } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { defineInstrument } from './defineInstrument';
5
+ import type { RenderContext } from './types';
6
+ import { validateConfigSchema } from './validateConfigSchema';
7
+
8
+ interface SineState {
9
+ frame: number;
10
+ }
11
+
12
+ interface SineConfig {
13
+ frequency: number;
14
+ amplitude: number;
15
+ showGrid: boolean;
16
+ }
17
+
18
+ const SineWaveInstrument = defineInstrument<SineState, SineConfig>({
19
+ name: 'Sine Wave',
20
+ configSchema: () => [
21
+ {
22
+ key: 'frequency',
23
+ label: 'Frequency',
24
+ type: 'slider',
25
+ min: 0.1,
26
+ max: 10,
27
+ step: 0.1,
28
+ default: 2,
29
+ },
30
+ { key: 'amplitude', label: 'Amplitude', type: 'slider', min: 0.1, max: 1, default: 0.5 },
31
+ { key: 'showGrid', label: 'Show grid', type: 'checkbox', default: true },
32
+ ],
33
+ defaultConfig: () => ({ frequency: 2, amplitude: 0.5, showGrid: true }),
34
+ initialState: () => ({ frame: 0 }),
35
+ render: ({ state }) => <div className="lk-sine-wave">frame: {state.frame}</div>,
36
+ onConfigChange: (_config, _prev, state) => ({ ...state, frame: 0 }),
37
+ serialize: (state) => ({ frame: state.frame }),
38
+ deserialize: (data) => {
39
+ const d = data as { frame?: number };
40
+ return { frame: typeof d.frame === 'number' ? d.frame : 0 };
41
+ },
42
+ });
43
+
44
+ describe('SineWave instrument smoke test', () => {
45
+ it('configSchema validates clean', () => {
46
+ const result = validateConfigSchema(SineWaveInstrument.configSchema?.() ?? []);
47
+ expect(result.valid).toBe(true);
48
+ });
49
+
50
+ it('initialState produces { frame: 0 }', () => {
51
+ const s = SineWaveInstrument.initialState(SineWaveInstrument.defaultConfig());
52
+ expect(s).toEqual({ frame: 0 });
53
+ });
54
+
55
+ it('onConfigChange resets frame', () => {
56
+ const next = SineWaveInstrument.onConfigChange?.(
57
+ { frequency: 5, amplitude: 0.5, showGrid: true },
58
+ { frequency: 2, amplitude: 0.5, showGrid: true },
59
+ { frame: 3 },
60
+ );
61
+ expect(next).toEqual({ frame: 0 });
62
+ });
63
+
64
+ it('serialize round-trips', () => {
65
+ expect(SineWaveInstrument.serialize?.({ frame: 7 })).toEqual({ frame: 7 });
66
+ });
67
+
68
+ it('deserialize preserves valid data', () => {
69
+ expect(
70
+ SineWaveInstrument.deserialize?.({ frame: 7 }, SineWaveInstrument.defaultConfig()),
71
+ ).toEqual({
72
+ frame: 7,
73
+ });
74
+ });
75
+
76
+ it('deserialize falls back when field missing', () => {
77
+ expect(SineWaveInstrument.deserialize?.({}, SineWaveInstrument.defaultConfig())).toEqual({
78
+ frame: 0,
79
+ });
80
+ });
81
+
82
+ it('renders into the DOM', () => {
83
+ const ctx: RenderContext<SineState, SineConfig> = {
84
+ state: { frame: 0 },
85
+ config: SineWaveInstrument.defaultConfig(),
86
+ setState: vi.fn(),
87
+ setConfig: vi.fn(),
88
+ workspace: { id: 'w', zoom: 1, setZoom: vi.fn() },
89
+ emit: vi.fn(),
90
+ };
91
+ const { container } = render(SineWaveInstrument.render(ctx) as ReactElement);
92
+ expect(container.querySelector('.lk-sine-wave')).not.toBeNull();
93
+ });
94
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { detectCapabilities } from './capabilityDetector';
3
+ import type { Instrument } from './types';
4
+
5
+ function baseInstrument(extras: Partial<Instrument>): Instrument {
6
+ return {
7
+ name: 'T',
8
+ defaultConfig: () => ({}),
9
+ initialState: () => ({}),
10
+ render: () => null,
11
+ ...extras,
12
+ };
13
+ }
14
+
15
+ describe('detectCapabilities', () => {
16
+ it('returns all false for a bare instrument', () => {
17
+ expect(detectCapabilities(baseInstrument({}))).toEqual({
18
+ hasCanvas: false,
19
+ hasLayers: false,
20
+ hasDragDrop: false,
21
+ hasUndo: false,
22
+ });
23
+ });
24
+
25
+ it('detects canvas only', () => {
26
+ const inst = baseInstrument({ canvas: { layers: [] } });
27
+ expect(detectCapabilities(inst)).toEqual({
28
+ hasCanvas: true,
29
+ hasLayers: false,
30
+ hasDragDrop: false,
31
+ hasUndo: false,
32
+ });
33
+ });
34
+
35
+ it('detects layers only', () => {
36
+ const inst = baseInstrument({ layers: { ids: ['a'] } });
37
+ expect(detectCapabilities(inst).hasLayers).toBe(true);
38
+ });
39
+
40
+ it('detects dragDrop only', () => {
41
+ const inst = baseInstrument({ dragDrop: { palette: [], onDrop: (_p, _i, s) => s } });
42
+ expect(detectCapabilities(inst).hasDragDrop).toBe(true);
43
+ });
44
+
45
+ it('detects undo only', () => {
46
+ const inst = baseInstrument({ undo: {} });
47
+ expect(detectCapabilities(inst).hasUndo).toBe(true);
48
+ });
49
+
50
+ it('detects all four capabilities', () => {
51
+ const inst = baseInstrument({
52
+ canvas: { layers: [] },
53
+ layers: { ids: [] },
54
+ dragDrop: { palette: [], onDrop: (_p, _i, s) => s },
55
+ undo: {},
56
+ });
57
+ expect(detectCapabilities(inst)).toEqual({
58
+ hasCanvas: true,
59
+ hasLayers: true,
60
+ hasDragDrop: true,
61
+ hasUndo: true,
62
+ });
63
+ });
64
+ });
@@ -0,0 +1,17 @@
1
+ import type { Instrument } from './types';
2
+
3
+ export interface CapabilityFlags {
4
+ hasCanvas: boolean;
5
+ hasLayers: boolean;
6
+ hasDragDrop: boolean;
7
+ hasUndo: boolean;
8
+ }
9
+
10
+ export function detectCapabilities(instrument: Instrument<unknown, unknown>): CapabilityFlags {
11
+ return {
12
+ hasCanvas: instrument.canvas != null,
13
+ hasLayers: instrument.layers != null,
14
+ hasDragDrop: instrument.dragDrop != null,
15
+ hasUndo: instrument.undo != null,
16
+ };
17
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import { defineInstrument } from './defineInstrument';
3
+ import type { Instrument } from './types';
4
+
5
+ describe('defineInstrument', () => {
6
+ it('returns the same object reference passed in', () => {
7
+ const spec: Instrument<{ n: number }, { x: number }> = {
8
+ name: 'Test',
9
+ defaultConfig: () => ({ x: 1 }),
10
+ initialState: (c) => ({ n: c.x }),
11
+ render: () => null,
12
+ };
13
+ const result = defineInstrument(spec);
14
+ expect(result).toBe(spec);
15
+ });
16
+
17
+ it('infers TS and TC from the literal', () => {
18
+ const inst = defineInstrument({
19
+ name: 'Inferred',
20
+ defaultConfig: () => ({ flag: true }),
21
+ initialState: (c) => ({ count: c.flag ? 1 : 0 }),
22
+ render: () => null,
23
+ });
24
+ expectTypeOf(inst.defaultConfig()).toEqualTypeOf<{ flag: boolean }>();
25
+ expectTypeOf(inst.initialState({ flag: true })).toEqualTypeOf<{ count: number }>();
26
+ });
27
+ });
@@ -0,0 +1,5 @@
1
+ import type { Instrument } from './types';
2
+
3
+ export function defineInstrument<TS, TC>(spec: Instrument<TS, TC>): Instrument<TS, TC> {
4
+ return spec;
5
+ }
@@ -0,0 +1,19 @@
1
+ export { defineInstrument } from './defineInstrument';
2
+ export type {
3
+ CanvasCapability,
4
+ CanvasLayer,
5
+ DragDropCapability,
6
+ DragFeedback,
7
+ HitResult,
8
+ Instrument,
9
+ LayerCapability,
10
+ LayerDescriptor,
11
+ PaletteItem,
12
+ Point,
13
+ RenderContext,
14
+ SystemEvent,
15
+ UndoCapability,
16
+ ViewTransform,
17
+ } from './types';
18
+ export type { ValidationResult } from './validateConfigSchema';
19
+ export { validateConfigSchema } from './validateConfigSchema';
@@ -0,0 +1,65 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ConfigField } from '../controls/types';
3
+
4
+ export interface RenderContext<TS = unknown, TC = unknown> {
5
+ state: TS;
6
+ config: TC;
7
+ setState: (next: TS | ((prev: TS) => TS)) => void;
8
+ setConfig: (key: keyof TC, value: unknown) => void;
9
+ workspace: {
10
+ id: string;
11
+ zoom: number;
12
+ setZoom: (z: number) => void;
13
+ };
14
+ emit: (event: string) => void;
15
+ }
16
+
17
+ export interface CanvasLayer<TS = unknown, TC = unknown> {
18
+ id: string;
19
+ draw: (ctx: CanvasRenderingContext2D, args: { state: TS; config: TC; zoom: number }) => void;
20
+ }
21
+
22
+ export interface CanvasCapability<TS = unknown, TC = unknown> {
23
+ layers: CanvasLayer<TS, TC>[];
24
+ initialView?: { zoom: number; pan: { x: number; y: number } };
25
+ }
26
+
27
+ export interface LayerCapability {
28
+ ids: string[];
29
+ }
30
+
31
+ export interface DragDropCapability<TS = unknown, TC = unknown> {
32
+ palette: PaletteItem[] | ((state: TS, config: TC) => PaletteItem[]);
33
+ onDrop: (worldPos: Point, item: PaletteItem, state: TS, config: TC) => TS;
34
+ onDragOver?: (worldPos: Point, item: PaletteItem, state: TS, config: TC) => DragFeedback | null;
35
+ pickUp?: (hit: HitResult, state: TS, config: TC) => { item: PaletteItem; state: TS } | null;
36
+ }
37
+
38
+ export interface UndoCapability {
39
+ snapshotOn?: string[];
40
+ maxDepth?: number;
41
+ }
42
+
43
+ export type SystemEvent = string;
44
+
45
+ export type Point = { x: number; y: number };
46
+ export type HitResult = { hit: boolean; layerId?: string; pointId?: string };
47
+ export type ViewTransform = { zoom: number; pan: Point };
48
+ export type LayerDescriptor = { id: string; label: string; alwaysOn?: boolean };
49
+ export type PaletteItem = { id: string; label: string; data?: unknown };
50
+ export type DragFeedback = { ok: boolean; reason?: string };
51
+
52
+ export interface Instrument<TS = unknown, TC = unknown> {
53
+ name: string;
54
+ defaultConfig: () => TC;
55
+ initialState: (config: TC) => TS;
56
+ configSchema?: () => ConfigField[];
57
+ render: (ctx: RenderContext<TS, TC>) => ReactNode;
58
+ onConfigChange?: (config: TC, prev: TC, state: TS) => TS;
59
+ serialize?: (state: TS) => unknown;
60
+ deserialize?: (data: unknown, config: TC) => TS;
61
+ canvas?: CanvasCapability<TS, TC>;
62
+ layers?: LayerCapability;
63
+ dragDrop?: DragDropCapability<TS, TC>;
64
+ undo?: UndoCapability;
65
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ConfigField } from '../controls/types';
3
+ import { validateConfigSchema } from './validateConfigSchema';
4
+
5
+ describe('validateConfigSchema', () => {
6
+ it('returns valid for a well-formed schema', () => {
7
+ const fields: ConfigField[] = [
8
+ { key: 'freq', label: 'Frequency', type: 'slider', min: 0, max: 10, default: 5 },
9
+ { key: 'on', label: 'On', type: 'checkbox', default: true },
10
+ ];
11
+ expect(validateConfigSchema(fields)).toEqual({ valid: true, errors: [] });
12
+ });
13
+
14
+ it('flags duplicate keys', () => {
15
+ const fields: ConfigField[] = [
16
+ { key: 'a', label: 'A', type: 'checkbox', default: true },
17
+ { key: 'a', label: 'A2', type: 'checkbox', default: false },
18
+ ];
19
+ const r = validateConfigSchema(fields);
20
+ expect(r.valid).toBe(false);
21
+ expect(r.errors).toContain('Duplicate config key: "a"');
22
+ });
23
+
24
+ it('flags unknown field type', () => {
25
+ const fields = [
26
+ { key: 'x', label: 'X', type: 'mystery', default: 1 },
27
+ ] as unknown as ConfigField[];
28
+ const r = validateConfigSchema(fields);
29
+ expect(r.errors).toContain('Unknown field type: "mystery" on key "x"');
30
+ });
31
+
32
+ it('flags slider min >= max', () => {
33
+ const fields: ConfigField[] = [
34
+ { key: 's', label: 'S', type: 'slider', min: 5, max: 5, default: 5 },
35
+ ];
36
+ const r = validateConfigSchema(fields);
37
+ expect(r.errors).toContain('Slider "s": min must be < max');
38
+ });
39
+
40
+ it('flags slider default outside [min, max]', () => {
41
+ const fields: ConfigField[] = [
42
+ { key: 's', label: 'S', type: 'slider', min: 0, max: 10, default: 99 },
43
+ ];
44
+ const r = validateConfigSchema(fields);
45
+ expect(r.errors).toContain('Slider "s": default 99 is outside [min, max]');
46
+ });
47
+
48
+ it('flags select with empty options', () => {
49
+ const fields: ConfigField[] = [
50
+ { key: 'sel', label: 'Sel', type: 'select', default: 'a', options: [] },
51
+ ];
52
+ const r = validateConfigSchema(fields);
53
+ expect(r.errors).toContain('Select "sel": options array must not be empty');
54
+ });
55
+
56
+ it('flags select with duplicate option values', () => {
57
+ const fields: ConfigField[] = [
58
+ {
59
+ key: 'sel',
60
+ label: 'Sel',
61
+ type: 'select',
62
+ default: 'a',
63
+ options: [
64
+ { value: 'a', label: 'A' },
65
+ { value: 'a', label: 'A2' },
66
+ ],
67
+ },
68
+ ];
69
+ const r = validateConfigSchema(fields);
70
+ expect(r.errors).toContain('Select "sel": duplicate option value "a"');
71
+ });
72
+
73
+ it('flags select default not in options', () => {
74
+ const fields: ConfigField[] = [
75
+ {
76
+ key: 'sel',
77
+ label: 'Sel',
78
+ type: 'select',
79
+ default: 'z',
80
+ options: [{ value: 'a', label: 'A' }],
81
+ },
82
+ ];
83
+ const r = validateConfigSchema(fields);
84
+ expect(r.errors).toContain('Select "sel": default "z" is not among options');
85
+ });
86
+
87
+ it('flags non-finite slider default', () => {
88
+ const fields: ConfigField[] = [
89
+ { key: 's', label: 'S', type: 'slider', min: 0, max: 10, default: Number.NaN },
90
+ ];
91
+ const r = validateConfigSchema(fields);
92
+ expect(r.errors).toContain('Field "s": default must be a finite number');
93
+ });
94
+
95
+ it('flags non-finite number default', () => {
96
+ const fields: ConfigField[] = [
97
+ { key: 'n', label: 'N', type: 'number', default: Number.POSITIVE_INFINITY },
98
+ ];
99
+ const r = validateConfigSchema(fields);
100
+ expect(r.errors).toContain('Field "n": default must be a finite number');
101
+ });
102
+
103
+ it('flags empty key', () => {
104
+ const fields: ConfigField[] = [{ key: '', label: 'L', type: 'checkbox', default: false }];
105
+ const r = validateConfigSchema(fields);
106
+ expect(r.errors).toContain('Field has empty key');
107
+ });
108
+
109
+ it('flags empty label', () => {
110
+ const fields: ConfigField[] = [{ key: 'k', label: '', type: 'checkbox', default: false }];
111
+ const r = validateConfigSchema(fields);
112
+ expect(r.errors).toContain('Field "k" has empty label');
113
+ });
114
+
115
+ it('collects multiple errors in a single call', () => {
116
+ const fields: ConfigField[] = [
117
+ { key: 'a', label: 'A', type: 'slider', min: 10, max: 0, default: 5 },
118
+ { key: 'a', label: 'A2', type: 'checkbox', default: true },
119
+ ];
120
+ const r = validateConfigSchema(fields);
121
+ expect(r.errors.length).toBeGreaterThanOrEqual(2);
122
+ expect(r.errors).toContain('Slider "a": min must be < max');
123
+ expect(r.errors).toContain('Duplicate config key: "a"');
124
+ });
125
+ });