@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,72 @@
1
+ import { type RefObject, useEffect, useRef } from 'react';
2
+ import type { ViewTransform } from '../instrument/types';
3
+
4
+ export interface CanvasLayerDescriptor {
5
+ id: string;
6
+ visible: boolean;
7
+ render: (ctx: CanvasRenderingContext2D, view: ViewTransform) => void;
8
+ }
9
+
10
+ interface SchedulerOptions {
11
+ layers: CanvasLayerDescriptor[];
12
+ view: ViewTransform;
13
+ canvasRefs: RefObject<Map<string, HTMLCanvasElement>>;
14
+ size: { width: number; height: number; dpr: number };
15
+ }
16
+
17
+ export function useLayerScheduler({ layers, view, canvasRefs, size }: SchedulerOptions): void {
18
+ const dirty = useRef<Set<string>>(new Set());
19
+ const rafId = useRef<number | null>(null);
20
+ const lastRenderRef = useRef<Map<string, CanvasLayerDescriptor['render']>>(new Map());
21
+
22
+ useEffect(() => {
23
+ for (const layer of layers) {
24
+ const prev = lastRenderRef.current.get(layer.id);
25
+ if (prev !== layer.render) dirty.current.add(layer.id);
26
+ lastRenderRef.current.set(layer.id, layer.render);
27
+ }
28
+ for (const id of [...lastRenderRef.current.keys()]) {
29
+ if (!layers.find((l) => l.id === id)) lastRenderRef.current.delete(id);
30
+ }
31
+ }, [layers]);
32
+
33
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-mark dirty when view or size changes
34
+ useEffect(() => {
35
+ for (const layer of layers) dirty.current.add(layer.id);
36
+ }, [view, size, layers]);
37
+
38
+ useEffect(() => {
39
+ const tick = () => {
40
+ rafId.current = null;
41
+ if (dirty.current.size === 0) return;
42
+ const map = canvasRefs.current;
43
+ if (!map) {
44
+ dirty.current.clear();
45
+ return;
46
+ }
47
+ for (const layer of layers) {
48
+ if (!dirty.current.has(layer.id)) continue;
49
+ if (!layer.visible) continue;
50
+ const canvas = map.get(layer.id);
51
+ if (!canvas) continue;
52
+ const ctx = canvas.getContext('2d');
53
+ if (!ctx) continue;
54
+ ctx.save();
55
+ ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
56
+ ctx.clearRect(0, 0, size.width, size.height);
57
+ layer.render(ctx, view);
58
+ ctx.restore();
59
+ }
60
+ dirty.current.clear();
61
+ };
62
+ if (dirty.current.size > 0 && rafId.current === null) {
63
+ rafId.current = requestAnimationFrame(tick);
64
+ }
65
+ return () => {
66
+ if (rafId.current !== null) {
67
+ cancelAnimationFrame(rafId.current);
68
+ rafId.current = null;
69
+ }
70
+ };
71
+ });
72
+ }
@@ -0,0 +1,98 @@
1
+ import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
2
+ import type { ViewTransform } from '../instrument/types';
3
+
4
+ export interface UsePanZoomOptions {
5
+ view: ViewTransform;
6
+ onViewChange: (v: ViewTransform) => void;
7
+ minZoom?: number;
8
+ maxZoom?: number;
9
+ }
10
+
11
+ export interface PanZoomHandlers {
12
+ onWheel: (e: WheelEvent<HTMLElement>) => void;
13
+ onPointerDown: (e: PointerEvent<HTMLElement>) => void;
14
+ onPointerMove: (e: PointerEvent<HTMLElement>) => void;
15
+ onPointerUp: (e: PointerEvent<HTMLElement>) => void;
16
+ isDragging: () => boolean;
17
+ }
18
+
19
+ interface DragState {
20
+ pointerId: number;
21
+ startScreenX: number;
22
+ startScreenY: number;
23
+ startPan: { x: number; y: number };
24
+ moved: boolean;
25
+ }
26
+
27
+ const DRAG_THRESHOLD = 3;
28
+
29
+ export function usePanZoom({
30
+ view,
31
+ onViewChange,
32
+ minZoom = 0.1,
33
+ maxZoom = 32,
34
+ }: UsePanZoomOptions): PanZoomHandlers {
35
+ const dragRef = useRef<DragState | null>(null);
36
+ const viewRef = useRef(view);
37
+ viewRef.current = view;
38
+
39
+ const onWheel = useCallback(
40
+ (e: WheelEvent<HTMLElement>) => {
41
+ e.preventDefault();
42
+ const rect = e.currentTarget.getBoundingClientRect();
43
+ const cursorX = e.clientX - rect.left;
44
+ const cursorY = e.clientY - rect.top;
45
+ const v = viewRef.current;
46
+ const factor = Math.exp(-e.deltaY * 0.001);
47
+ const nextZoom = Math.min(maxZoom, Math.max(minZoom, v.zoom * factor));
48
+ const ratio = nextZoom / v.zoom;
49
+ const nextPan = {
50
+ x: cursorX - (cursorX - v.pan.x) * ratio,
51
+ y: cursorY - (cursorY - v.pan.y) * ratio,
52
+ };
53
+ onViewChange({ zoom: nextZoom, pan: nextPan });
54
+ },
55
+ [onViewChange, minZoom, maxZoom],
56
+ );
57
+
58
+ const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
59
+ if (e.button !== 0) return;
60
+ e.currentTarget.setPointerCapture(e.pointerId);
61
+ dragRef.current = {
62
+ pointerId: e.pointerId,
63
+ startScreenX: e.clientX,
64
+ startScreenY: e.clientY,
65
+ startPan: { ...viewRef.current.pan },
66
+ moved: false,
67
+ };
68
+ }, []);
69
+
70
+ const onPointerMove = useCallback(
71
+ (e: PointerEvent<HTMLElement>) => {
72
+ const drag = dragRef.current;
73
+ if (!drag || drag.pointerId !== e.pointerId) return;
74
+ const dx = e.clientX - drag.startScreenX;
75
+ const dy = e.clientY - drag.startScreenY;
76
+ if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
77
+ drag.moved = true;
78
+ onViewChange({
79
+ zoom: viewRef.current.zoom,
80
+ pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
81
+ });
82
+ },
83
+ [onViewChange],
84
+ );
85
+
86
+ const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
87
+ const drag = dragRef.current;
88
+ if (!drag || drag.pointerId !== e.pointerId) return;
89
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
90
+ e.currentTarget.releasePointerCapture(e.pointerId);
91
+ }
92
+ dragRef.current = null;
93
+ }, []);
94
+
95
+ const isDragging = useCallback(() => dragRef.current?.moved === true, []);
96
+
97
+ return { onWheel, onPointerDown, onPointerMove, onPointerUp, isDragging };
98
+ }
@@ -0,0 +1,61 @@
1
+ .lk-control-panel {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--lk-spacing-sm, 8px);
5
+ padding: var(--lk-spacing-sm, 8px);
6
+ }
7
+
8
+ .lk-control-row {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: var(--lk-spacing-xs, 4px);
12
+ }
13
+
14
+ .lk-control-row--inline {
15
+ flex-direction: row;
16
+ align-items: center;
17
+ gap: var(--lk-spacing-sm, 8px);
18
+ }
19
+
20
+ .lk-control-label {
21
+ font-size: var(--lk-font-size-sm, 12px);
22
+ color: var(--lk-color-text, #222);
23
+ }
24
+
25
+ .lk-control-input {
26
+ width: 100%;
27
+ background: var(--lk-color-surface, #fff);
28
+ color: var(--lk-color-text, #222);
29
+ border: 1px solid var(--lk-color-border, #ccc);
30
+ border-radius: 4px;
31
+ padding: 2px 6px;
32
+ }
33
+
34
+ .lk-control-input--range {
35
+ padding: 0;
36
+ border: none;
37
+ }
38
+
39
+ .lk-control-input--checkbox {
40
+ width: auto;
41
+ }
42
+
43
+ .lk-control-input--color {
44
+ width: 40px;
45
+ height: 24px;
46
+ padding: 0;
47
+ }
48
+
49
+ .lk-control-slider-value {
50
+ font-size: var(--lk-font-size-sm, 12px);
51
+ font-variant-numeric: tabular-nums;
52
+ color: var(--lk-color-text-muted, #666);
53
+ min-width: 3ch;
54
+ text-align: right;
55
+ }
56
+
57
+ .lk-control-slider-row {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: var(--lk-spacing-sm, 8px);
61
+ }
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { ControlPanel } from './ControlPanel';
4
+ import type { ConfigField } from './types';
5
+
6
+ const meta: Meta<typeof ControlPanel> = {
7
+ title: 'labkit/Controls/ControlPanel',
8
+ component: ControlPanel,
9
+ parameters: { layout: 'padded' },
10
+ };
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof ControlPanel>;
14
+
15
+ const allFields: ConfigField[] = [
16
+ {
17
+ key: 'frequency',
18
+ label: 'Frequency',
19
+ type: 'slider',
20
+ min: 0.1,
21
+ max: 10,
22
+ step: 0.1,
23
+ default: 2,
24
+ },
25
+ {
26
+ key: 'amplitude',
27
+ label: 'Amplitude',
28
+ type: 'slider',
29
+ min: 0,
30
+ max: 1,
31
+ step: 0.05,
32
+ default: 0.5,
33
+ },
34
+ { key: 'showGrid', label: 'Show grid', type: 'checkbox', default: true },
35
+ {
36
+ key: 'wave',
37
+ label: 'Wave',
38
+ type: 'select',
39
+ default: 'sine',
40
+ options: [
41
+ { value: 'sine', label: 'Sine' },
42
+ { value: 'square', label: 'Square' },
43
+ { value: 'triangle', label: 'Triangle' },
44
+ ],
45
+ },
46
+ { key: 'samples', label: 'Samples', type: 'number', default: 256, min: 16, max: 4096, step: 16 },
47
+ { key: 'title', label: 'Title', type: 'text', default: 'My experiment', placeholder: 'Title…' },
48
+ { key: 'tint', label: 'Tint', type: 'color', default: '#3a86ff' },
49
+ ];
50
+
51
+ function Harness({ fields }: { fields: ConfigField[] }) {
52
+ const initial: Record<string, unknown> = {};
53
+ for (const f of fields) initial[f.key] = f.default;
54
+ const [config, setConfig] = useState<Record<string, unknown>>(initial);
55
+ return (
56
+ <ControlPanel
57
+ fields={fields}
58
+ config={config}
59
+ setConfig={(key, value) => setConfig((prev) => ({ ...prev, [key as string]: value }))}
60
+ />
61
+ );
62
+ }
63
+
64
+ export const Default: Story = {
65
+ render: () => <Harness fields={allFields} />,
66
+ };
67
+
68
+ export const Minimal: Story = {
69
+ render: () => (
70
+ <Harness
71
+ fields={[
72
+ { key: 'speed', label: 'Speed', type: 'slider', min: 0, max: 10, default: 5 },
73
+ { key: 'enabled', label: 'Enabled', type: 'checkbox', default: true },
74
+ ]}
75
+ />
76
+ ),
77
+ };
@@ -0,0 +1,149 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { ControlPanel } from './ControlPanel';
4
+ import type { ConfigField } from './types';
5
+
6
+ describe('<ControlPanel> slider', () => {
7
+ it('renders a range input with min/max/step and label', () => {
8
+ const fields: ConfigField[] = [
9
+ { key: 'freq', label: 'Frequency', type: 'slider', min: 0, max: 10, step: 0.5, default: 2 },
10
+ ];
11
+ render(<ControlPanel fields={fields} config={{ freq: 2 }} setConfig={vi.fn()} />);
12
+ const input = screen.getByLabelText('Frequency') as HTMLInputElement;
13
+ expect(input.type).toBe('range');
14
+ expect(input.min).toBe('0');
15
+ expect(input.max).toBe('10');
16
+ expect(input.step).toBe('0.5');
17
+ expect(screen.getByText('2')).toBeInTheDocument();
18
+ });
19
+
20
+ it('calls setConfig with numeric value on change', () => {
21
+ const setConfig = vi.fn();
22
+ const fields: ConfigField[] = [
23
+ { key: 'freq', label: 'Frequency', type: 'slider', min: 0, max: 10, default: 2 },
24
+ ];
25
+ render(<ControlPanel fields={fields} config={{ freq: 2 }} setConfig={setConfig} />);
26
+ fireEvent.change(screen.getByLabelText('Frequency'), { target: { value: '5' } });
27
+ expect(setConfig).toHaveBeenCalledWith('freq', 5);
28
+ });
29
+ });
30
+
31
+ describe('<ControlPanel> checkbox', () => {
32
+ it('renders checkbox with label and reflects config', () => {
33
+ const fields: ConfigField[] = [{ key: 'on', label: 'On', type: 'checkbox', default: false }];
34
+ render(<ControlPanel fields={fields} config={{ on: true }} setConfig={vi.fn()} />);
35
+ const cb = screen.getByLabelText('On') as HTMLInputElement;
36
+ expect(cb.type).toBe('checkbox');
37
+ expect(cb.checked).toBe(true);
38
+ });
39
+
40
+ it('calls setConfig with boolean on change', () => {
41
+ const setConfig = vi.fn();
42
+ const fields: ConfigField[] = [{ key: 'on', label: 'On', type: 'checkbox', default: false }];
43
+ render(<ControlPanel fields={fields} config={{ on: false }} setConfig={setConfig} />);
44
+ fireEvent.click(screen.getByLabelText('On'));
45
+ expect(setConfig).toHaveBeenCalledWith('on', true);
46
+ });
47
+ });
48
+
49
+ describe('<ControlPanel> select', () => {
50
+ it('renders options and dispatches selected value', () => {
51
+ const setConfig = vi.fn();
52
+ const fields: ConfigField[] = [
53
+ {
54
+ key: 'wave',
55
+ label: 'Wave',
56
+ type: 'select',
57
+ default: 'sine',
58
+ options: [
59
+ { value: 'sine', label: 'Sine' },
60
+ { value: 'square', label: 'Square' },
61
+ ],
62
+ },
63
+ ];
64
+ render(<ControlPanel fields={fields} config={{ wave: 'sine' }} setConfig={setConfig} />);
65
+ const sel = screen.getByLabelText('Wave') as HTMLSelectElement;
66
+ expect(sel.tagName).toBe('SELECT');
67
+ fireEvent.change(sel, { target: { value: 'square' } });
68
+ expect(setConfig).toHaveBeenCalledWith('wave', 'square');
69
+ });
70
+ });
71
+
72
+ describe('<ControlPanel> number', () => {
73
+ it('renders number input with initial value', () => {
74
+ const fields: ConfigField[] = [
75
+ { key: 'n', label: 'N', type: 'number', default: 0, min: 0, max: 100 },
76
+ ];
77
+ render(<ControlPanel fields={fields} config={{ n: 42 }} setConfig={vi.fn()} />);
78
+ const input = screen.getByLabelText('N') as HTMLInputElement;
79
+ expect(input.type).toBe('number');
80
+ expect(input.value).toBe('42');
81
+ });
82
+
83
+ it('clamps to [min, max] on blur', () => {
84
+ const setConfig = vi.fn();
85
+ const fields: ConfigField[] = [
86
+ { key: 'n', label: 'N', type: 'number', default: 0, min: 0, max: 100 },
87
+ ];
88
+ render(<ControlPanel fields={fields} config={{ n: 50 }} setConfig={setConfig} />);
89
+ const input = screen.getByLabelText('N');
90
+ fireEvent.blur(input, { target: { value: '999' } });
91
+ expect(setConfig).toHaveBeenCalledWith('n', 100);
92
+ });
93
+ });
94
+
95
+ describe('<ControlPanel> text', () => {
96
+ it('renders text input with placeholder and maxLength', () => {
97
+ const fields: ConfigField[] = [
98
+ {
99
+ key: 't',
100
+ label: 'T',
101
+ type: 'text',
102
+ default: '',
103
+ placeholder: 'type here',
104
+ maxLength: 10,
105
+ },
106
+ ];
107
+ render(<ControlPanel fields={fields} config={{ t: 'hi' }} setConfig={vi.fn()} />);
108
+ const input = screen.getByLabelText('T') as HTMLInputElement;
109
+ expect(input.type).toBe('text');
110
+ expect(input.placeholder).toBe('type here');
111
+ expect(input.maxLength).toBe(10);
112
+ });
113
+
114
+ it('calls setConfig live when debounceMs is 0', () => {
115
+ const setConfig = vi.fn();
116
+ const fields: ConfigField[] = [
117
+ { key: 't', label: 'T', type: 'text', default: '', debounceMs: 0 },
118
+ ];
119
+ render(<ControlPanel fields={fields} config={{ t: '' }} setConfig={setConfig} />);
120
+ fireEvent.change(screen.getByLabelText('T'), { target: { value: 'a' } });
121
+ expect(setConfig).toHaveBeenCalledWith('t', 'a');
122
+ });
123
+ });
124
+
125
+ describe('<ControlPanel> color', () => {
126
+ it('renders a color input reflecting config and dispatches value', () => {
127
+ const setConfig = vi.fn();
128
+ const fields: ConfigField[] = [{ key: 'c', label: 'Color', type: 'color', default: '#000000' }];
129
+ render(<ControlPanel fields={fields} config={{ c: '#ff0000' }} setConfig={setConfig} />);
130
+ const input = screen.getByLabelText('Color') as HTMLInputElement;
131
+ expect(input.type).toBe('color');
132
+ expect(input.value).toBe('#ff0000');
133
+ fireEvent.change(input, { target: { value: '#00ff00' } });
134
+ expect(setConfig).toHaveBeenCalledWith('c', '#00ff00');
135
+ });
136
+ });
137
+
138
+ describe('<ControlPanel> defensive', () => {
139
+ it('renders nothing for an unknown field type without crashing', () => {
140
+ const fields = [
141
+ { key: 'mystery', label: 'Mystery', type: 'mystery', default: 1 },
142
+ ] as unknown as ConfigField[];
143
+ const { container } = render(
144
+ <ControlPanel fields={fields} config={{ mystery: 1 }} setConfig={vi.fn()} />,
145
+ );
146
+ expect(container.querySelector('.lk-control-panel')).not.toBeNull();
147
+ expect(container.querySelector('.lk-control-row')).toBeNull();
148
+ });
149
+ });