@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,255 @@
1
+ import { type ReactNode, useContext, useMemo, useRef, useState } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import { CanvasStack } from '../canvas/CanvasStack';
4
+ import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
5
+ import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
6
+ import { Palette } from '../dragdrop/Palette';
7
+ import type {
8
+ Instrument,
9
+ LayerDescriptor,
10
+ PaletteItem,
11
+ RenderContext,
12
+ ViewTransform,
13
+ } from '../instrument/types';
14
+ import { useLabContext } from '../lab/LabContext';
15
+ import { LayerList } from '../layers/LayerList';
16
+ import { LabStoreContext } from '../state/context';
17
+ import type { LabStore } from '../state/store';
18
+ import type { WorkspaceRecord } from '../state/types';
19
+ import { createEventBus, type EventBus } from '../undo/eventBus';
20
+ import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
21
+ import type { UndoBindings } from './WorkspaceChrome';
22
+ import { WorkspaceChrome } from './WorkspaceChrome';
23
+
24
+ export interface WorkspaceProps {
25
+ id: string;
26
+ }
27
+
28
+ export function Workspace({ id }: WorkspaceProps) {
29
+ const lab = useLabContext();
30
+ const storeCtx = useContext(LabStoreContext);
31
+ if (!storeCtx) throw new Error('[labkit] <Workspace> requires <LabStoreProvider>');
32
+ const record = useStore(storeCtx.store, (s) => s.workspaces.find((w) => w.id === id));
33
+ if (!record) {
34
+ return <div className="lk-workspace lk-workspace--unknown">Workspace not found: {id}</div>;
35
+ }
36
+ const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
37
+ if (!instrument) {
38
+ return (
39
+ <div className="lk-workspace lk-workspace--unknown">
40
+ Unknown instrument: {record.instrumentName}
41
+ </div>
42
+ );
43
+ }
44
+ return (
45
+ <WorkspaceRuntime
46
+ record={record}
47
+ instrument={instrument}
48
+ store={storeCtx.store}
49
+ isLast={lab.workspaces.length <= 1}
50
+ />
51
+ );
52
+ }
53
+
54
+ interface WorkspaceRuntimeProps {
55
+ record: WorkspaceRecord;
56
+ instrument: Instrument;
57
+ store: LabStore;
58
+ isLast: boolean;
59
+ }
60
+
61
+ function WorkspaceRuntime({ record, instrument, store, isLast }: WorkspaceRuntimeProps) {
62
+ const canvasContainerRef = useRef<HTMLDivElement | null>(null);
63
+ const updateWorkspaceState = useStore(store, (s) => s.updateWorkspaceState);
64
+ const updateWorkspaceConfig = useStore(store, (s) => s.updateWorkspaceConfig);
65
+ const updateWorkspaceView = useStore(store, (s) => s.updateWorkspaceView);
66
+ const updateWorkspaceUndoStack = useStore(store, (s) => s.updateWorkspaceUndoStack);
67
+
68
+ const busRef = useRef<EventBus | null>(null);
69
+ if (busRef.current === null) busRef.current = createEventBus();
70
+ const bus = busRef.current;
71
+
72
+ const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>({});
73
+ const [layerOrder, setLayerOrder] = useState<string[] | null>(null);
74
+
75
+ const undoCap = instrument.undo;
76
+ const undoEvents = useMemo(() => new Set(undoCap?.snapshotOn ?? ['state.change']), [undoCap]);
77
+ const maxDepth = undoCap?.maxDepth ?? 50;
78
+
79
+ const snapshotIfNeeded = (event: string): void => {
80
+ if (!undoCap || !undoEvents.has(event)) return;
81
+ const current = store.getState().workspaces.find((w) => w.id === record.id);
82
+ if (!current) return;
83
+ const snap = structuredClone(current.state);
84
+ updateWorkspaceUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
85
+ };
86
+
87
+ const setView = (v: ViewTransform): void => updateWorkspaceView(record.id, v);
88
+
89
+ const renderCtx: RenderContext<unknown, unknown> = {
90
+ state: record.state,
91
+ config: record.config,
92
+ setState: (next) => {
93
+ snapshotIfNeeded('state.change');
94
+ updateWorkspaceState(record.id, next);
95
+ bus.emit('state.change');
96
+ },
97
+ setConfig: (key, value) => {
98
+ const evt = `config.change:${String(key)}`;
99
+ snapshotIfNeeded('config.change');
100
+ snapshotIfNeeded(evt);
101
+ updateWorkspaceConfig(record.id, key as never, value as never);
102
+ bus.emit('config.change');
103
+ bus.emit(evt);
104
+ },
105
+ workspace: {
106
+ id: record.id,
107
+ zoom: record.view.zoom,
108
+ setZoom: (z) => updateWorkspaceView(record.id, { ...record.view, zoom: z }),
109
+ },
110
+ emit: (event) => {
111
+ snapshotIfNeeded(event);
112
+ bus.emit(event);
113
+ },
114
+ };
115
+
116
+ const undoBindings: UndoBindings | undefined = undoCap
117
+ ? {
118
+ canUndo: record.undoStack.past.length > 0,
119
+ canRedo: record.undoStack.future.length > 0,
120
+ undo: () => {
121
+ const result = undoUndo(record.undoStack, structuredClone(record.state));
122
+ if (!result) return;
123
+ updateWorkspaceState(record.id, result.snapshot as never);
124
+ updateWorkspaceUndoStack(record.id, result.stack);
125
+ },
126
+ redo: () => {
127
+ const result = undoRedo(record.undoStack, structuredClone(record.state));
128
+ if (!result) return;
129
+ updateWorkspaceState(record.id, result.snapshot as never);
130
+ updateWorkspaceUndoStack(record.id, result.stack);
131
+ },
132
+ }
133
+ : undefined;
134
+
135
+ const canvasLayers: CanvasLayerDescriptor[] = useMemo(() => {
136
+ if (!instrument.canvas) return [];
137
+ const baseLayers = instrument.canvas.layers;
138
+ const ordered = layerOrder
139
+ ? [...baseLayers].sort((a, b) => layerOrder.indexOf(a.id) - layerOrder.indexOf(b.id))
140
+ : baseLayers;
141
+ return ordered.map((layer) => ({
142
+ id: layer.id,
143
+ visible: layerVisibility[layer.id] !== false,
144
+ render: (ctx, view) =>
145
+ layer.draw(ctx, { state: record.state, config: record.config, zoom: view.zoom }),
146
+ }));
147
+ }, [instrument.canvas, record.state, record.config, layerVisibility, layerOrder]);
148
+
149
+ const layerDescriptors: LayerDescriptor[] = useMemo(() => {
150
+ if (!instrument.layers) return [];
151
+ return instrument.layers.ids.map((lid) => ({ id: lid, label: lid }));
152
+ }, [instrument.layers]);
153
+
154
+ const dragDropResult = useDragDrop({
155
+ capability: instrument.dragDrop ?? { palette: [], onDrop: (_p, _i, s) => s },
156
+ canvasContainerRef,
157
+ view: record.view,
158
+ state: record.state,
159
+ config: record.config,
160
+ setState: (next) => {
161
+ snapshotIfNeeded('canvas.itemAdded');
162
+ updateWorkspaceState(record.id, next as never);
163
+ },
164
+ emit: (evt) => {
165
+ snapshotIfNeeded(evt);
166
+ bus.emit(evt);
167
+ },
168
+ });
169
+
170
+ const paletteItems: PaletteItem[] = useMemo(() => {
171
+ if (!instrument.dragDrop) return [];
172
+ const p = instrument.dragDrop.palette;
173
+ return typeof p === 'function' ? p(record.state, record.config) : p;
174
+ }, [instrument.dragDrop, record.state, record.config]);
175
+
176
+ const layersWithFeedback: CanvasLayerDescriptor[] = useMemo(() => {
177
+ if (!dragDropResult.drag?.feedback) return canvasLayers;
178
+ const fb = dragDropResult.drag.feedback;
179
+ const screen = dragDropResult.drag.screenPos;
180
+ return [
181
+ ...canvasLayers,
182
+ {
183
+ id: '__lk_drag_feedback',
184
+ visible: true,
185
+ render: (ctx) => {
186
+ const el = canvasContainerRef.current;
187
+ if (!el) return;
188
+ const r = el.getBoundingClientRect();
189
+ ctx.save();
190
+ ctx.strokeStyle = fb.ok ? '#3a7' : '#c44';
191
+ ctx.lineWidth = 2;
192
+ ctx.beginPath();
193
+ ctx.arc(screen.x - r.left, screen.y - r.top, 16, 0, Math.PI * 2);
194
+ ctx.stroke();
195
+ ctx.restore();
196
+ },
197
+ },
198
+ ];
199
+ }, [canvasLayers, dragDropResult.drag]);
200
+
201
+ let body: ReactNode;
202
+ if (instrument.canvas) {
203
+ body = (
204
+ <div
205
+ ref={canvasContainerRef}
206
+ className="lk-workspace__canvas-host"
207
+ style={{ width: '100%', height: '100%', position: 'relative' }}
208
+ >
209
+ <CanvasStack layers={layersWithFeedback} view={record.view} onViewChange={setView} />
210
+ <DragOverlay drag={dragDropResult.drag} />
211
+ </div>
212
+ );
213
+ } else {
214
+ body = instrument.render(renderCtx);
215
+ }
216
+
217
+ const paletteNode =
218
+ paletteItems.length > 0 ? (
219
+ <Palette items={paletteItems} onDragStart={dragDropResult.startDrag} />
220
+ ) : null;
221
+
222
+ const layerListNode =
223
+ instrument.layers && layerDescriptors.length > 0 ? (
224
+ <LayerList
225
+ layers={layerDescriptors}
226
+ visibility={layerVisibility}
227
+ onReorder={(next) => {
228
+ setLayerOrder(next.map((l) => l.id));
229
+ bus.emit('layers.reorder');
230
+ }}
231
+ onToggle={(lid, visible) => {
232
+ setLayerVisibility((prev) => ({ ...prev, [lid]: visible }));
233
+ bus.emit('layers.toggle');
234
+ }}
235
+ />
236
+ ) : null;
237
+
238
+ return (
239
+ <WorkspaceChrome
240
+ workspaceId={record.id}
241
+ record={record}
242
+ instrument={instrument}
243
+ isLastWorkspace={isLast}
244
+ undoBindings={undoBindings}
245
+ sidebarExtras={
246
+ <>
247
+ {paletteNode}
248
+ {layerListNode}
249
+ </>
250
+ }
251
+ >
252
+ {body}
253
+ </WorkspaceChrome>
254
+ );
255
+ }
@@ -0,0 +1,150 @@
1
+ import { type KeyboardEvent, type ReactNode, useContext, useMemo } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import type { Instrument } from '../instrument/types';
4
+ import { useLabContext } from '../lab/LabContext';
5
+ import { LabStoreContext } from '../state/context';
6
+ import type { WorkspaceRecord } from '../state/types';
7
+ import { DefaultSidebar } from './DefaultSidebar';
8
+ import { DefaultStatusBar } from './DefaultStatusBar';
9
+ import { DefaultToolbar } from './DefaultToolbar';
10
+ import type {
11
+ SidebarSlot,
12
+ StatusBarSlot,
13
+ ToolbarSlot,
14
+ WorkspaceSidebarContext,
15
+ WorkspaceStatusBarContext,
16
+ WorkspaceToolbarContext,
17
+ } from './slotTypes';
18
+
19
+ export interface UndoBindings {
20
+ canUndo: boolean;
21
+ canRedo: boolean;
22
+ undo: () => void;
23
+ redo: () => void;
24
+ }
25
+
26
+ export interface WorkspaceChromeProps {
27
+ workspaceId: string;
28
+ record: WorkspaceRecord;
29
+ instrument: Instrument;
30
+ isLastWorkspace: boolean;
31
+ toolbar?: ToolbarSlot;
32
+ sidebar?: SidebarSlot;
33
+ statusBar?: StatusBarSlot;
34
+ undoBindings?: UndoBindings;
35
+ sidebarExtras?: ReactNode;
36
+ children: ReactNode;
37
+ }
38
+
39
+ export function WorkspaceChrome({
40
+ workspaceId,
41
+ record,
42
+ instrument,
43
+ isLastWorkspace,
44
+ toolbar,
45
+ sidebar,
46
+ statusBar,
47
+ undoBindings,
48
+ sidebarExtras,
49
+ children,
50
+ }: WorkspaceChromeProps) {
51
+ const lab = useLabContext();
52
+ const storeCtx = useContext(LabStoreContext);
53
+ if (!storeCtx) throw new Error('[labkit] WorkspaceChrome requires <LabStoreProvider>');
54
+ const updateWorkspaceView = useStore(storeCtx.store, (s) => s.updateWorkspaceView);
55
+ const updateWorkspaceConfig = useStore(storeCtx.store, (s) => s.updateWorkspaceConfig);
56
+ const updateWorkspaceState = useStore(storeCtx.store, (s) => s.updateWorkspaceState);
57
+
58
+ const toolbarCtx = useMemo<WorkspaceToolbarContext>(() => {
59
+ const setZoom = (z: number): void => {
60
+ updateWorkspaceView(workspaceId, { ...record.view, zoom: z });
61
+ };
62
+ return {
63
+ workspaceId,
64
+ instrumentName: record.instrumentName,
65
+ hasUndo: instrument.undo != null,
66
+ canUndo: undoBindings?.canUndo ?? false,
67
+ canRedo: undoBindings?.canRedo ?? false,
68
+ undo: undoBindings?.undo ?? (() => {}),
69
+ redo: undoBindings?.redo ?? (() => {}),
70
+ zoom: record.view.zoom,
71
+ setZoom,
72
+ zoomIn: () => setZoom(record.view.zoom * 1.25),
73
+ zoomOut: () => setZoom(record.view.zoom * 0.8),
74
+ resetZoom: () => setZoom(1),
75
+ hasCanvas: instrument.canvas != null,
76
+ savedSnapshots: lab.savedSnapshots.filter((s) => s.workspaceId === workspaceId),
77
+ saveSnapshot: (name) => lab.saveSnapshot(workspaceId, name),
78
+ loadSnapshot: (snapshotId) => lab.loadSnapshot(workspaceId, snapshotId),
79
+ clone: () => lab.cloneWorkspace(workspaceId),
80
+ reset: () => lab.resetWorkspace(workspaceId),
81
+ close: () => lab.closeWorkspace(workspaceId),
82
+ isLastWorkspace,
83
+ };
84
+ }, [workspaceId, record, instrument, lab, isLastWorkspace, updateWorkspaceView, undoBindings]);
85
+
86
+ const sidebarCtx = useMemo<WorkspaceSidebarContext>(
87
+ () => ({
88
+ workspaceId,
89
+ instrumentName: record.instrumentName,
90
+ configFields: instrument.configSchema?.() ?? [],
91
+ config: record.config,
92
+ setConfig: (key, value) => {
93
+ const prevConfig = record.config as Record<string, unknown>;
94
+ if (process.env.NODE_ENV !== 'production' && !(key in prevConfig)) {
95
+ console.warn(
96
+ `[labkit] setConfig: unknown key "${key}" for instrument "${record.instrumentName}"`,
97
+ );
98
+ }
99
+ updateWorkspaceConfig(workspaceId, key as never, value as never);
100
+ if (instrument.onConfigChange) {
101
+ const nextConfig = { ...prevConfig, [key]: value };
102
+ const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
103
+ updateWorkspaceState(workspaceId, nextState as never);
104
+ }
105
+ },
106
+ }),
107
+ [workspaceId, record, instrument, updateWorkspaceConfig, updateWorkspaceState],
108
+ );
109
+
110
+ const statusCtx: WorkspaceStatusBarContext = {
111
+ workspaceId,
112
+ instrumentName: record.instrumentName,
113
+ zoom: record.view.zoom,
114
+ };
115
+
116
+ const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
117
+ const mod = e.metaKey || e.ctrlKey;
118
+ if (!mod) return;
119
+ if (e.key === 'z' || e.key === 'Z') {
120
+ e.preventDefault();
121
+ if (e.shiftKey) toolbarCtx.redo();
122
+ else toolbarCtx.undo();
123
+ } else if (e.key === 's' || e.key === 'S') {
124
+ e.preventDefault();
125
+ toolbarCtx.saveSnapshot();
126
+ }
127
+ };
128
+
129
+ return (
130
+ <section
131
+ className="lk-workspace"
132
+ aria-label={`Workspace ${record.instrumentName}`}
133
+ onKeyDown={handleKeyDown}
134
+ >
135
+ <div className="lk-workspace__toolbar">
136
+ {toolbar ? toolbar(toolbarCtx) : <DefaultToolbar ctx={toolbarCtx} />}
137
+ </div>
138
+ <div className="lk-workspace__body">
139
+ <div className="lk-workspace__sidebar">
140
+ {sidebar ? sidebar(sidebarCtx) : <DefaultSidebar ctx={sidebarCtx} />}
141
+ {sidebarExtras}
142
+ </div>
143
+ <div className="lk-workspace__content">{children}</div>
144
+ </div>
145
+ <div className="lk-workspace__status">
146
+ {statusBar ? statusBar(statusCtx) : <DefaultStatusBar ctx={statusCtx} />}
147
+ </div>
148
+ </section>
149
+ );
150
+ }
@@ -0,0 +1,24 @@
1
+ export type { DefaultSidebarProps } from './DefaultSidebar';
2
+ export { DefaultSidebar } from './DefaultSidebar';
3
+ export type { DefaultStatusBarProps } from './DefaultStatusBar';
4
+ export { DefaultStatusBar } from './DefaultStatusBar';
5
+ export type { DefaultToolbarProps } from './DefaultToolbar';
6
+ export { DefaultToolbar } from './DefaultToolbar';
7
+ export type {
8
+ SidebarSlot,
9
+ StatusBarSlot,
10
+ ToolbarSlot,
11
+ WorkspaceSidebarContext,
12
+ WorkspaceStatusBarContext,
13
+ WorkspaceToolbarContext,
14
+ } from './slotTypes';
15
+ export type { WorkspaceProps } from './Workspace';
16
+ export { Workspace } from './Workspace';
17
+ export type { WorkspaceChromeProps } from './WorkspaceChrome';
18
+ export { WorkspaceChrome } from './WorkspaceChrome';
19
+ export {
20
+ addWorkspace,
21
+ cloneWorkspace,
22
+ closeWorkspace,
23
+ resetWorkspace,
24
+ } from './workspaceOps';
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ConfigField } from '../controls/types';
3
+ import type { SavedSnapshot } from '../state/types';
4
+
5
+ export interface WorkspaceToolbarContext {
6
+ workspaceId: string;
7
+ instrumentName: string;
8
+ hasUndo: boolean;
9
+ canUndo: boolean;
10
+ canRedo: boolean;
11
+ undo: () => void;
12
+ redo: () => void;
13
+ zoom: number;
14
+ setZoom: (z: number) => void;
15
+ zoomIn: () => void;
16
+ zoomOut: () => void;
17
+ resetZoom: () => void;
18
+ hasCanvas: boolean;
19
+ savedSnapshots: SavedSnapshot[];
20
+ saveSnapshot: (name?: string) => void;
21
+ loadSnapshot: (snapshotId: string) => void;
22
+ clone: () => void;
23
+ reset: () => void;
24
+ close: () => void;
25
+ isLastWorkspace: boolean;
26
+ }
27
+
28
+ export interface WorkspaceSidebarContext {
29
+ workspaceId: string;
30
+ instrumentName: string;
31
+ configFields: ConfigField[];
32
+ config: unknown;
33
+ setConfig: (key: string, value: unknown) => void;
34
+ }
35
+
36
+ export interface WorkspaceStatusBarContext {
37
+ workspaceId: string;
38
+ instrumentName: string;
39
+ zoom: number;
40
+ }
41
+
42
+ export type ToolbarSlot = (ctx: WorkspaceToolbarContext) => ReactNode;
43
+ export type SidebarSlot = (ctx: WorkspaceSidebarContext) => ReactNode;
44
+ export type StatusBarSlot = (ctx: WorkspaceStatusBarContext) => ReactNode;
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Instrument } from '../instrument/types';
3
+ import type { WorkspaceRecord } from '../state/types';
4
+ import { addWorkspace, cloneWorkspace, closeWorkspace, resetWorkspace } from './workspaceOps';
5
+
6
+ interface CounterState {
7
+ count: number;
8
+ }
9
+ interface CounterConfig {
10
+ step: number;
11
+ }
12
+
13
+ const counter: Instrument<CounterState, CounterConfig> = {
14
+ name: 'Counter',
15
+ defaultConfig: () => ({ step: 1 }),
16
+ initialState: (config) => ({ count: 0 * config.step }),
17
+ render: () => null,
18
+ };
19
+
20
+ const counterWithCanvas: Instrument<CounterState, CounterConfig> = {
21
+ ...counter,
22
+ name: 'CounterCanvas',
23
+ canvas: {
24
+ layers: [],
25
+ initialView: { zoom: 2, pan: { x: 5, y: 7 } },
26
+ },
27
+ };
28
+
29
+ const instruments: Instrument[] = [counter as Instrument, counterWithCanvas as Instrument];
30
+
31
+ function head<T>(arr: T[]): T {
32
+ const first = arr[0];
33
+ if (first === undefined) throw new Error('expected non-empty array');
34
+ return first;
35
+ }
36
+
37
+ describe('addWorkspace', () => {
38
+ it('appends a new record using defaultConfig and initialState', () => {
39
+ const out = addWorkspace([], instruments, 'Counter');
40
+ expect(out).toHaveLength(1);
41
+ expect(out[0]?.instrumentName).toBe('Counter');
42
+ expect(out[0]?.config).toEqual({ step: 1 });
43
+ expect(out[0]?.state).toEqual({ count: 0 });
44
+ });
45
+
46
+ it('assigns a unique id', () => {
47
+ const a = addWorkspace([], instruments, 'Counter');
48
+ const b = addWorkspace(a, instruments, 'Counter');
49
+ expect(b).toHaveLength(2);
50
+ expect(b[0]?.id).not.toBe(b[1]?.id);
51
+ });
52
+
53
+ it('uses canvas.initialView when present', () => {
54
+ const out = addWorkspace([], instruments, 'CounterCanvas');
55
+ expect(out[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
56
+ });
57
+
58
+ it('defaults view when canvas absent', () => {
59
+ const out = addWorkspace([], instruments, 'Counter');
60
+ expect(out[0]?.view).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
61
+ });
62
+
63
+ it('throws when instrumentName is unknown', () => {
64
+ expect(() => addWorkspace([], instruments, 'Missing')).toThrow();
65
+ });
66
+
67
+ it('does not mutate input array', () => {
68
+ const arr: WorkspaceRecord[] = [];
69
+ addWorkspace(arr, instruments, 'Counter');
70
+ expect(arr).toHaveLength(0);
71
+ });
72
+ });
73
+
74
+ describe('cloneWorkspace', () => {
75
+ it('inserts clone immediately after source with new id', () => {
76
+ const a = addWorkspace([], instruments, 'Counter');
77
+ const sourceId = head(a).id;
78
+ const cloned = cloneWorkspace(a, sourceId);
79
+ expect(cloned).toHaveLength(2);
80
+ expect(cloned[0]?.id).toBe(sourceId);
81
+ expect(cloned[1]?.id).not.toBe(sourceId);
82
+ expect(cloned[1]?.instrumentName).toBe('Counter');
83
+ });
84
+
85
+ it('deep-copies config/state/view', () => {
86
+ const a = addWorkspace([], instruments, 'Counter');
87
+ const cloned = cloneWorkspace(a, head(a).id);
88
+ expect(cloned[1]?.config).not.toBe(cloned[0]?.config);
89
+ expect(cloned[1]?.state).not.toBe(cloned[0]?.state);
90
+ expect(cloned[1]?.view).not.toBe(cloned[0]?.view);
91
+ });
92
+
93
+ it('does not mutate input', () => {
94
+ const a = addWorkspace([], instruments, 'Counter');
95
+ const before = a.length;
96
+ cloneWorkspace(a, head(a).id);
97
+ expect(a).toHaveLength(before);
98
+ });
99
+ });
100
+
101
+ describe('closeWorkspace', () => {
102
+ it('removes the workspace with the given id', () => {
103
+ let arr = addWorkspace([], instruments, 'Counter');
104
+ arr = addWorkspace(arr, instruments, 'Counter');
105
+ const closed = closeWorkspace(arr, head(arr).id);
106
+ expect(closed).toHaveLength(1);
107
+ expect(closed[0]?.id).toBe(arr[1]?.id);
108
+ });
109
+
110
+ it('is a no-op when only one workspace remains', () => {
111
+ const arr = addWorkspace([], instruments, 'Counter');
112
+ const closed = closeWorkspace(arr, head(arr).id);
113
+ expect(closed).toEqual(arr);
114
+ });
115
+ });
116
+
117
+ describe('resetWorkspace', () => {
118
+ it('resets config and state to defaults; preserves instrumentName', () => {
119
+ const arr = addWorkspace([], instruments, 'Counter');
120
+ const id = head(arr).id;
121
+ head(arr).config = { step: 99 } as CounterConfig;
122
+ head(arr).state = { count: 42 } as CounterState;
123
+ const reset = resetWorkspace(arr, id, instruments);
124
+ expect(reset[0]?.config).toEqual({ step: 1 });
125
+ expect(reset[0]?.state).toEqual({ count: 0 });
126
+ expect(reset[0]?.instrumentName).toBe('Counter');
127
+ });
128
+
129
+ it('resets view to canvas.initialView when present', () => {
130
+ const arr = addWorkspace([], instruments, 'CounterCanvas');
131
+ head(arr).view = { zoom: 9, pan: { x: 9, y: 9 } };
132
+ const reset = resetWorkspace(arr, head(arr).id, instruments);
133
+ expect(reset[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
134
+ });
135
+
136
+ it('does not mutate input', () => {
137
+ const arr = addWorkspace([], instruments, 'Counter');
138
+ head(arr).config = { step: 99 } as CounterConfig;
139
+ resetWorkspace(arr, head(arr).id, instruments);
140
+ expect((arr[0]?.config as CounterConfig).step).toBe(99);
141
+ });
142
+ });
@@ -0,0 +1,78 @@
1
+ import type { Instrument } from '../instrument/types';
2
+ import type { WorkspaceRecord } from '../state/types';
3
+
4
+ const DEFAULT_VIEW = { zoom: 1, pan: { x: 0, y: 0 } } as const;
5
+
6
+ function findInstrument(instruments: Instrument[], name: string): Instrument {
7
+ const found = instruments.find((i) => i.name === name);
8
+ if (!found) {
9
+ throw new Error(`[labkit] Unknown instrument: "${name}"`);
10
+ }
11
+ return found;
12
+ }
13
+
14
+ function initialView(instrument: Instrument): WorkspaceRecord['view'] {
15
+ return instrument.canvas?.initialView
16
+ ? structuredClone(instrument.canvas.initialView)
17
+ : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
18
+ }
19
+
20
+ export function addWorkspace(
21
+ workspaces: WorkspaceRecord[],
22
+ instruments: Instrument[],
23
+ instrumentName: string,
24
+ ): WorkspaceRecord[] {
25
+ const instrument = findInstrument(instruments, instrumentName);
26
+ const config = instrument.defaultConfig();
27
+ const state = instrument.initialState(config);
28
+ const record: WorkspaceRecord = {
29
+ id: crypto.randomUUID(),
30
+ instrumentName,
31
+ config,
32
+ state,
33
+ view: initialView(instrument),
34
+ undoStack: { past: [], future: [] },
35
+ };
36
+ return [...workspaces, record];
37
+ }
38
+
39
+ export function cloneWorkspace(workspaces: WorkspaceRecord[], id: string): WorkspaceRecord[] {
40
+ const sourceIdx = workspaces.findIndex((w) => w.id === id);
41
+ const source = workspaces[sourceIdx];
42
+ if (!source) return workspaces;
43
+ const clone: WorkspaceRecord = {
44
+ ...source,
45
+ id: crypto.randomUUID(),
46
+ config: structuredClone(source.config),
47
+ state: structuredClone(source.state),
48
+ view: structuredClone(source.view),
49
+ undoStack: { past: [], future: [] },
50
+ };
51
+ return [...workspaces.slice(0, sourceIdx + 1), clone, ...workspaces.slice(sourceIdx + 1)];
52
+ }
53
+
54
+ export function closeWorkspace(workspaces: WorkspaceRecord[], id: string): WorkspaceRecord[] {
55
+ if (workspaces.length <= 1) return workspaces;
56
+ const next = workspaces.filter((w) => w.id !== id);
57
+ return next.length === workspaces.length ? workspaces : next;
58
+ }
59
+
60
+ export function resetWorkspace(
61
+ workspaces: WorkspaceRecord[],
62
+ id: string,
63
+ instruments: Instrument[],
64
+ ): WorkspaceRecord[] {
65
+ const idx = workspaces.findIndex((w) => w.id === id);
66
+ const current = workspaces[idx];
67
+ if (!current) return workspaces;
68
+ const instrument = findInstrument(instruments, current.instrumentName);
69
+ const config = instrument.defaultConfig();
70
+ const state = instrument.initialState(config);
71
+ const reset: WorkspaceRecord = {
72
+ ...current,
73
+ config,
74
+ state,
75
+ view: initialView(instrument),
76
+ };
77
+ return [...workspaces.slice(0, idx), reset, ...workspaces.slice(idx + 1)];
78
+ }