@weasel-js/labkit 1.0.4 → 1.2.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 (122) hide show
  1. package/README.md +33 -6
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
  4. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  5. package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
  6. package/dist/canvas/index.d.ts +2 -1
  7. package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
  8. package/dist/chunk-73KA7WBO.js.map +1 -0
  9. package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
  10. package/dist/chunk-BOHF3PQO.js.map +1 -0
  11. package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
  12. package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
  13. package/dist/chunk-LN6JDUGB.js +106 -0
  14. package/dist/chunk-LN6JDUGB.js.map +1 -0
  15. package/dist/chunk-THBG7FQZ.js +167 -0
  16. package/dist/chunk-THBG7FQZ.js.map +1 -0
  17. package/dist/chunk-VUU5UXHE.js +491 -0
  18. package/dist/chunk-VUU5UXHE.js.map +1 -0
  19. package/dist/dragdrop/index.d.ts +2 -1
  20. package/dist/index.d.ts +211 -151
  21. package/dist/index.js +317 -169
  22. package/dist/index.js.map +1 -1
  23. package/dist/job/index.d.ts +13 -0
  24. package/dist/job/index.js +3 -0
  25. package/dist/job/index.js.map +1 -0
  26. package/dist/layers/index.d.ts +3 -2
  27. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  28. package/dist/passthrough/weasel-canvas.js +1 -1
  29. package/dist/passthrough/weasel-ui.d.ts +29 -6
  30. package/dist/passthrough/weasel-ui.js +2 -2
  31. package/dist/state/index.d.ts +12 -12
  32. package/dist/state/index.js +5 -5
  33. package/dist/state/index.js.map +1 -1
  34. package/dist/styles.css +31 -13
  35. package/dist/surface/index.d.ts +77 -0
  36. package/dist/surface/index.js +3 -0
  37. package/dist/surface/index.js.map +1 -0
  38. package/dist/ui/layers/index.js +3 -3
  39. package/dist/undo/index.d.ts +2 -1
  40. package/package.json +9 -1
  41. package/src/canvas/AGENTS.md +14 -2
  42. package/src/canvas/useOrbit.test.ts +71 -0
  43. package/src/canvas/useOrbit.ts +149 -0
  44. package/src/dragdrop/dragDrop.test.tsx +4 -7
  45. package/src/index.test.ts +70 -0
  46. package/src/index.ts +24 -7
  47. package/src/instrument/SineWave.smoke.test.tsx +2 -1
  48. package/src/instrument/capabilityDetector.ts +1 -1
  49. package/src/instrument/types.ts +30 -6
  50. package/src/job/index.ts +3 -0
  51. package/src/job/types.ts +47 -0
  52. package/src/job/useJob.test.tsx +210 -0
  53. package/src/job/useJob.ts +134 -0
  54. package/src/lab/Lab.less +16 -0
  55. package/src/lab/Lab.stories.tsx +6 -7
  56. package/src/lab/Lab.test.tsx +25 -25
  57. package/src/lab/Lab.tsx +50 -50
  58. package/src/lab/LabContext.ts +12 -12
  59. package/src/lab/LabShell.tsx +1 -1
  60. package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
  61. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
  62. package/src/lab/Workspace.surface.test.tsx +49 -0
  63. package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
  64. package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
  65. package/src/lab/index.ts +2 -2
  66. package/src/layers/AGENTS.md +3 -3
  67. package/src/primitives/Toolbar.stories.tsx +2 -2
  68. package/src/state/SingletonExperiment.test.tsx +8 -8
  69. package/src/state/SingletonExperiment.tsx +9 -9
  70. package/src/state/context.tsx +12 -12
  71. package/src/state/document.test.ts +369 -0
  72. package/src/state/document.ts +194 -0
  73. package/src/state/helpers.test.ts +74 -46
  74. package/src/state/helpers.ts +21 -29
  75. package/src/state/index.ts +16 -8
  76. package/src/state/store.test.ts +440 -41
  77. package/src/state/store.ts +165 -102
  78. package/src/state/types.ts +32 -13
  79. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  80. package/src/state/useTrialState.ts +29 -0
  81. package/src/state/view.test.ts +127 -0
  82. package/src/state/view.ts +18 -0
  83. package/src/styles.less +2 -2
  84. package/src/surface/AGENTS.md +64 -0
  85. package/src/surface/SurfaceContext.ts +5 -0
  86. package/src/surface/composeRects.test.ts +50 -0
  87. package/src/surface/composeRects.ts +19 -0
  88. package/src/surface/deviceRect.test.ts +40 -0
  89. package/src/surface/deviceRect.ts +19 -0
  90. package/src/surface/index.ts +7 -0
  91. package/src/surface/rect.ts +16 -0
  92. package/src/surface/useSurfaceTile.test.tsx +67 -0
  93. package/src/surface/useSurfaceTile.ts +32 -0
  94. package/src/surface/useTiledSurface.test.tsx +231 -0
  95. package/src/surface/useTiledSurface.ts +157 -0
  96. package/src/theme/base.less +1 -1
  97. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  98. package/src/trial/DefaultStatusBar.tsx +19 -0
  99. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  100. package/src/trial/Trial.canvas.test.tsx +80 -0
  101. package/src/trial/Trial.job.test.tsx +75 -0
  102. package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
  103. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
  104. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
  105. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
  106. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
  107. package/src/{workspace → trial}/index.ts +13 -13
  108. package/src/{workspace → trial}/slotTypes.ts +18 -17
  109. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
  110. package/src/trial/trialOps.ts +99 -0
  111. package/src/ui/format.test.ts +33 -0
  112. package/src/ui/format.ts +11 -0
  113. package/src/ui/properties/CurveField.tsx +6 -6
  114. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  115. package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
  116. package/dist/chunk-BAPZPDDA.js.map +0 -1
  117. package/dist/chunk-DWV7SFKR.js.map +0 -1
  118. package/dist/chunk-KSTEW2AF.js +0 -367
  119. package/dist/chunk-KSTEW2AF.js.map +0 -1
  120. package/src/state/useExperimentState.ts +0 -31
  121. package/src/workspace/DefaultStatusBar.tsx +0 -18
  122. package/src/workspace/workspaceOps.ts +0 -102
@@ -0,0 +1,231 @@
1
+ import { act, render } from '@testing-library/react';
2
+ import { useEffect } from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import type { SurfaceFrame, SurfaceHandle } from './useTiledSurface';
5
+ import { useTiledSurface } from './useTiledSurface';
6
+
7
+ /** jsdom measures everything as zero, so each element is given a box by hand. */
8
+ function stubBox(el: HTMLElement, left: number, top: number, width: number, height: number): void {
9
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
10
+ left,
11
+ top,
12
+ width,
13
+ height,
14
+ right: left + width,
15
+ bottom: top + height,
16
+ x: left,
17
+ y: top,
18
+ toJSON: () => ({}),
19
+ } as DOMRect);
20
+ }
21
+
22
+ interface HarnessProps {
23
+ frames: SurfaceFrame[];
24
+ onHandle: (h: SurfaceHandle) => void;
25
+ }
26
+
27
+ function Harness({ frames, onHandle }: HarnessProps) {
28
+ const surface = useTiledSurface({
29
+ onFrame: (f) => {
30
+ frames.push(f);
31
+ },
32
+ });
33
+ useEffect(() => {
34
+ onHandle(surface);
35
+ }, [surface, onHandle]);
36
+ return (
37
+ <div
38
+ data-testid="stage"
39
+ ref={(el) => {
40
+ if (!el) return;
41
+ stubBox(el, 0, 0, 800, 600);
42
+ surface.containerRef(el);
43
+ }}
44
+ >
45
+ <div
46
+ data-testid="a"
47
+ ref={(el) => {
48
+ if (!el) return;
49
+ stubBox(el, 0, 0, 400, 600);
50
+ surface.registerTile('a', el);
51
+ }}
52
+ />
53
+ <div
54
+ data-testid="b"
55
+ ref={(el) => {
56
+ if (!el) return;
57
+ stubBox(el, 400, 0, 400, 600);
58
+ surface.registerTile('b', el);
59
+ }}
60
+ />
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /** Runs every pending rAF callback. */
66
+ function flushFrames(): void {
67
+ act(() => {
68
+ vi.advanceTimersByTime(64);
69
+ });
70
+ }
71
+
72
+ describe('useTiledSurface', () => {
73
+ beforeEach(() => {
74
+ vi.useFakeTimers();
75
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
76
+ setTimeout(() => cb(0), 16),
77
+ );
78
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
79
+ });
80
+ afterEach(() => {
81
+ vi.useRealTimers();
82
+ vi.unstubAllGlobals();
83
+ vi.restoreAllMocks();
84
+ });
85
+
86
+ it('coalesces three invalidations in one tick into a single frame', () => {
87
+ const frames: SurfaceFrame[] = [];
88
+ let handle: SurfaceHandle | null = null;
89
+ render(
90
+ <Harness
91
+ frames={frames}
92
+ onHandle={(h) => {
93
+ handle = h;
94
+ }}
95
+ />,
96
+ );
97
+ flushFrames();
98
+ frames.length = 0;
99
+
100
+ act(() => {
101
+ handle?.invalidate('a');
102
+ handle?.invalidate('b');
103
+ handle?.invalidate('a');
104
+ });
105
+ flushFrames();
106
+
107
+ expect(frames).toHaveLength(1);
108
+ expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
109
+ });
110
+
111
+ it('fires nothing on a clean tick', () => {
112
+ const frames: SurfaceFrame[] = [];
113
+ render(<Harness frames={frames} onHandle={() => {}} />);
114
+ flushFrames();
115
+ frames.length = 0;
116
+ flushFrames();
117
+ expect(frames).toHaveLength(0);
118
+ });
119
+
120
+ it('carries every tile rect, not only the dirty ones', () => {
121
+ const frames: SurfaceFrame[] = [];
122
+ let handle: SurfaceHandle | null = null;
123
+ render(
124
+ <Harness
125
+ frames={frames}
126
+ onHandle={(h) => {
127
+ handle = h;
128
+ }}
129
+ />,
130
+ );
131
+ flushFrames();
132
+ frames.length = 0;
133
+
134
+ act(() => handle?.invalidate('a'));
135
+ flushFrames();
136
+
137
+ expect([...(frames[0]?.dirty ?? [])]).toEqual(['a']);
138
+ expect(frames[0]?.rects.get('b')).toEqual({ x: 400, y: 0, w: 400, h: 600 });
139
+ });
140
+
141
+ it('reports the surface size and dpr', () => {
142
+ vi.stubGlobal('devicePixelRatio', 3);
143
+ const frames: SurfaceFrame[] = [];
144
+ let handle: SurfaceHandle | null = null;
145
+ render(
146
+ <Harness
147
+ frames={frames}
148
+ onHandle={(h) => {
149
+ handle = h;
150
+ }}
151
+ />,
152
+ );
153
+ flushFrames();
154
+ frames.length = 0;
155
+
156
+ act(() => handle?.invalidateAll());
157
+ flushFrames();
158
+
159
+ expect(frames[0]?.dpr).toBe(3);
160
+ expect(frames[0]?.size).toEqual({ width: 800, height: 600 });
161
+ });
162
+
163
+ it('marks every tile dirty when the dpr changes', () => {
164
+ vi.stubGlobal('devicePixelRatio', 1);
165
+ const frames: SurfaceFrame[] = [];
166
+ let handle: SurfaceHandle | null = null;
167
+ render(
168
+ <Harness
169
+ frames={frames}
170
+ onHandle={(h) => {
171
+ handle = h;
172
+ }}
173
+ />,
174
+ );
175
+ flushFrames();
176
+ frames.length = 0;
177
+
178
+ vi.stubGlobal('devicePixelRatio', 2);
179
+ act(() => handle?.invalidateRects());
180
+ flushFrames();
181
+
182
+ expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
183
+ expect(frames[0]?.dpr).toBe(2);
184
+ });
185
+
186
+ it('re-measures on invalidateRects, which is how a moved tile is caught', () => {
187
+ const frames: SurfaceFrame[] = [];
188
+ let handle: SurfaceHandle | null = null;
189
+ const { getByTestId } = render(
190
+ <Harness
191
+ frames={frames}
192
+ onHandle={(h) => {
193
+ handle = h;
194
+ }}
195
+ />,
196
+ );
197
+ flushFrames();
198
+ frames.length = 0;
199
+
200
+ // A sibling reflow slides tile b left. ResizeObserver sees no size change.
201
+ stubBox(getByTestId('b'), 320, 0, 400, 600);
202
+
203
+ act(() => handle?.invalidateRects());
204
+ flushFrames();
205
+
206
+ expect(frames[0]?.rects.get('b')).toEqual({ x: 320, y: 0, w: 400, h: 600 });
207
+ });
208
+
209
+ it('drops a tile that unregisters', () => {
210
+ const frames: SurfaceFrame[] = [];
211
+ let handle: SurfaceHandle | null = null;
212
+ render(
213
+ <Harness
214
+ frames={frames}
215
+ onHandle={(h) => {
216
+ handle = h;
217
+ }}
218
+ />,
219
+ );
220
+ flushFrames();
221
+ frames.length = 0;
222
+
223
+ act(() => {
224
+ handle?.registerTile('b', null);
225
+ handle?.invalidate('a');
226
+ });
227
+ flushFrames();
228
+
229
+ expect(frames[0]?.rects.has('b')).toBe(false);
230
+ });
231
+ });
@@ -0,0 +1,157 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { composeRects, rectsEqual } from './composeRects';
3
+ import type { Box, Rect } from './rect';
4
+
5
+ /** What a surface owner is handed once per animation frame. `rects` carries every
6
+ * tile, not only the dirty ones: a scissored draw has to know where it is drawing
7
+ * relative to a surface that may have resized under it. */
8
+ export interface SurfaceFrame {
9
+ dirty: ReadonlySet<string>;
10
+ rects: ReadonlyMap<string, Rect>;
11
+ dpr: number;
12
+ size: { width: number; height: number };
13
+ }
14
+
15
+ /** The invalidators and the two ref callbacks that publish geometry. */
16
+ export interface SurfaceHandle {
17
+ /** Mark one tile for redraw. */
18
+ invalidate: (id: string) => void;
19
+ /** Mark every tile — what a resize or a tile-set change means. */
20
+ invalidateAll: () => void;
21
+ /** Re-measure before the next frame. The escape hatch for a host that knows it
22
+ * moved something a ResizeObserver cannot see. */
23
+ invalidateRects: () => void;
24
+ registerTile: (id: string, el: HTMLElement | null) => void;
25
+ containerRef: (el: HTMLElement | null) => void;
26
+ }
27
+
28
+ export interface UseTiledSurfaceOptions {
29
+ onFrame: (frame: SurfaceFrame) => void;
30
+ }
31
+
32
+ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHandle {
33
+ const container = useRef<HTMLElement | null>(null);
34
+ const tiles = useRef(new Map<string, HTMLElement>());
35
+ const rects = useRef(new Map<string, Rect>());
36
+ const dirty = useRef(new Set<string>());
37
+ const needsMeasure = useRef(true);
38
+ const raf = useRef(0);
39
+ const observer = useRef<ResizeObserver | null>(null);
40
+ const lastDpr = useRef(0);
41
+
42
+ // Held in a ref so a caller passing an inline closure does not re-create every
43
+ // callback below on each render.
44
+ const onFrameRef = useRef(onFrame);
45
+ onFrameRef.current = onFrame;
46
+
47
+ const measure = useCallback((): boolean => {
48
+ const el = container.current;
49
+ if (!el) return false;
50
+ const boxes = new Map<string, Box>();
51
+ for (const [id, tile] of tiles.current) boxes.set(id, tile.getBoundingClientRect());
52
+ const next = composeRects(el.getBoundingClientRect(), boxes);
53
+ let changed = next.size !== rects.current.size;
54
+ for (const [id, rect] of next) {
55
+ if (!rectsEqual(rects.current.get(id), rect)) changed = true;
56
+ }
57
+ rects.current = next;
58
+ return changed;
59
+ }, []);
60
+
61
+ const schedule = useCallback(() => {
62
+ if (raf.current) return;
63
+ raf.current = requestAnimationFrame(() => {
64
+ raf.current = 0;
65
+ const el = container.current;
66
+ if (!el) {
67
+ dirty.current.clear();
68
+ return;
69
+ }
70
+ if (needsMeasure.current) {
71
+ needsMeasure.current = false;
72
+ if (measure()) for (const id of rects.current.keys()) dirty.current.add(id);
73
+ }
74
+ const dpr = globalThis.devicePixelRatio ?? 1;
75
+ if (dpr !== lastDpr.current) {
76
+ lastDpr.current = dpr;
77
+ for (const id of rects.current.keys()) dirty.current.add(id);
78
+ }
79
+ if (dirty.current.size === 0) return;
80
+ const box = el.getBoundingClientRect();
81
+ onFrameRef.current({
82
+ dirty: new Set(dirty.current),
83
+ rects: new Map(rects.current),
84
+ dpr,
85
+ size: { width: box.width, height: box.height },
86
+ });
87
+ dirty.current.clear();
88
+ });
89
+ }, [measure]);
90
+
91
+ const invalidate = useCallback(
92
+ (id: string) => {
93
+ dirty.current.add(id);
94
+ schedule();
95
+ },
96
+ [schedule],
97
+ );
98
+
99
+ const invalidateAll = useCallback(() => {
100
+ for (const id of tiles.current.keys()) dirty.current.add(id);
101
+ schedule();
102
+ }, [schedule]);
103
+
104
+ const invalidateRects = useCallback(() => {
105
+ needsMeasure.current = true;
106
+ schedule();
107
+ }, [schedule]);
108
+
109
+ const registerTile = useCallback(
110
+ (id: string, el: HTMLElement | null) => {
111
+ const known = tiles.current.get(id);
112
+ if (known === el) return;
113
+ if (known) observer.current?.unobserve(known);
114
+ if (el) {
115
+ tiles.current.set(id, el);
116
+ observer.current?.observe(el);
117
+ } else {
118
+ tiles.current.delete(id);
119
+ rects.current.delete(id);
120
+ dirty.current.delete(id);
121
+ }
122
+ needsMeasure.current = true;
123
+ schedule();
124
+ },
125
+ [schedule],
126
+ );
127
+
128
+ const containerRef = useCallback(
129
+ (el: HTMLElement | null) => {
130
+ if (container.current === el) return;
131
+ if (container.current) observer.current?.unobserve(container.current);
132
+ container.current = el;
133
+ if (el) observer.current?.observe(el);
134
+ needsMeasure.current = true;
135
+ schedule();
136
+ },
137
+ [schedule],
138
+ );
139
+
140
+ useEffect(() => {
141
+ const ro = new ResizeObserver(() => {
142
+ needsMeasure.current = true;
143
+ schedule();
144
+ });
145
+ observer.current = ro;
146
+ if (container.current) ro.observe(container.current);
147
+ for (const el of tiles.current.values()) ro.observe(el);
148
+ return () => {
149
+ ro.disconnect();
150
+ observer.current = null;
151
+ if (raf.current) cancelAnimationFrame(raf.current);
152
+ raf.current = 0;
153
+ };
154
+ }, [schedule]);
155
+
156
+ return { invalidate, invalidateAll, invalidateRects, registerTile, containerRef };
157
+ }
@@ -61,7 +61,7 @@
61
61
  }
62
62
 
63
63
  // Cosmic background applied to the Lab root. The starscape sits as a fixed
64
- // pseudo-element so panning the workspace doesn't shift the field of stars.
64
+ // pseudo-element so panning the trial doesn't shift the field of stars.
65
65
  .lk-lab {
66
66
  position: relative;
67
67
  isolation: isolate;
@@ -1,13 +1,13 @@
1
1
  import { ControlPanel } from '../controls/ControlPanel';
2
2
  import { Sidebar } from '../primitives/Sidebar';
3
- import type { WorkspaceSidebarContext } from './slotTypes';
3
+ import type { TrialSidebarContext } from './slotTypes';
4
4
 
5
5
  /** Props for `<DefaultSidebar>`. */
6
6
  export interface DefaultSidebarProps {
7
- ctx: WorkspaceSidebarContext;
7
+ ctx: TrialSidebarContext;
8
8
  }
9
9
 
10
- /** The sidebar a workspace renders when no `sidebar` slot is supplied: a
10
+ /** The sidebar a trial renders when no `sidebar` slot is supplied: a
11
11
  * control panel over the instrument's config schema. */
12
12
  export function DefaultSidebar({ ctx }: DefaultSidebarProps) {
13
13
  if (ctx.configFields.length === 0) {
@@ -0,0 +1,19 @@
1
+ import { StatusBar } from '../primitives/StatusBar';
2
+ import { formatZoom } from '../ui/format';
3
+ import type { TrialStatusBarContext } from './slotTypes';
4
+
5
+ /** Props for `<DefaultStatusBar>`. */
6
+ export interface DefaultStatusBarProps {
7
+ ctx: TrialStatusBarContext;
8
+ }
9
+
10
+ /** The status bar a trial renders when no `statusBar` slot is supplied:
11
+ * the instrument's name, and the zoom when the trial's view has one. */
12
+ export function DefaultStatusBar({ ctx }: DefaultStatusBarProps) {
13
+ return (
14
+ <StatusBar>
15
+ <StatusBar.Section>{ctx.instrumentName}</StatusBar.Section>
16
+ {ctx.zoom === null ? null : <StatusBar.Section>{formatZoom(ctx.zoom)}</StatusBar.Section>}
17
+ </StatusBar>
18
+ );
19
+ }
@@ -1,12 +1,13 @@
1
1
  import { Toolbar } from '../primitives/Toolbar';
2
- import type { WorkspaceToolbarContext } from './slotTypes';
2
+ import { formatZoom } from '../ui/format';
3
+ import type { TrialToolbarContext } from './slotTypes';
3
4
 
4
5
  /** Props for `<DefaultToolbar>`. */
5
6
  export interface DefaultToolbarProps {
6
- ctx: WorkspaceToolbarContext;
7
+ ctx: TrialToolbarContext;
7
8
  }
8
9
 
9
- /** The toolbar a workspace renders when no `toolbar` slot is supplied. Only
10
+ /** The toolbar a trial renders when no `toolbar` slot is supplied. Only
10
11
  * shows the controls the instrument's declared capabilities support. */
11
12
  export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
12
13
  return (
@@ -32,7 +33,7 @@ export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
32
33
  <Toolbar.Button onClick={ctx.zoomOut} title="Zoom out">
33
34
 
34
35
  </Toolbar.Button>
35
- <span className="lk-toolbar__zoom-label">{Math.round(ctx.zoom * 100)}%</span>
36
+ <span className="lk-toolbar__zoom-label">{formatZoom(ctx.zoom)}</span>
36
37
  <Toolbar.Button onClick={ctx.zoomIn} title="Zoom in">
37
38
  +
38
39
  </Toolbar.Button>
@@ -73,16 +74,16 @@ export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
73
74
 
74
75
  <Toolbar.Spacer />
75
76
 
76
- <Toolbar.Button onClick={ctx.clone} title="Clone workspace">
77
+ <Toolbar.Button onClick={ctx.clone} title="Clone trial">
77
78
  Clone
78
79
  </Toolbar.Button>
79
- <Toolbar.Button onClick={ctx.reset} title="Reset workspace">
80
+ <Toolbar.Button onClick={ctx.reset} title="Reset trial">
80
81
  Reset
81
82
  </Toolbar.Button>
82
83
  <Toolbar.Button
83
84
  onClick={ctx.close}
84
- disabled={ctx.isLastWorkspace}
85
- title={ctx.isLastWorkspace ? 'Cannot close the last workspace' : 'Close workspace'}
85
+ disabled={ctx.isLastTrial}
86
+ title={ctx.isLastTrial ? 'Cannot close the last trial' : 'Close trial'}
86
87
  >
87
88
  Close
88
89
  </Toolbar.Button>
@@ -0,0 +1,80 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
3
+ import { defineInstrument } from '../instrument/defineInstrument';
4
+ import { Lab } from '../lab/Lab';
5
+
6
+ interface St {
7
+ n: number;
8
+ }
9
+
10
+ const ctxSpies = {
11
+ save: vi.fn(),
12
+ restore: vi.fn(),
13
+ setTransform: vi.fn(),
14
+ clearRect: vi.fn(),
15
+ translate: vi.fn(),
16
+ scale: vi.fn(),
17
+ };
18
+
19
+ beforeAll(() => {
20
+ HTMLCanvasElement.prototype.getContext = vi.fn(
21
+ () => ctxSpies,
22
+ ) as unknown as HTMLCanvasElement['getContext'];
23
+ });
24
+
25
+ function makeInstrument(draw: (ctx: CanvasRenderingContext2D, args: unknown) => void) {
26
+ return defineInstrument<St, Record<string, never>>({
27
+ name: 'Probe',
28
+ defaultConfig: () => ({}),
29
+ initialState: () => ({ n: 7 }),
30
+ render: ({ state }) => <output data-testid="readout">n = {state.n}</output>,
31
+ canvas: {
32
+ initialView: { zoom: 2, pan: { x: 30, y: 40 } },
33
+ layers: [{ id: 'main', draw }],
34
+ },
35
+ });
36
+ }
37
+
38
+ describe('an instrument that draws', () => {
39
+ it('still renders its own DOM, as an overlay over the canvas', () => {
40
+ const { container } = render(
41
+ <Lab instruments={[makeInstrument(() => undefined)]} defaultInstrument="Probe" />,
42
+ );
43
+ expect(screen.getByTestId('readout')).toHaveTextContent('n = 7');
44
+ expect(container.querySelector('.lk-canvas-stack__overlay')).toContainElement(
45
+ screen.getByTestId('readout'),
46
+ );
47
+ expect(container.querySelectorAll('canvas').length).toBeGreaterThan(0);
48
+ });
49
+
50
+ it('draws with the camera applied, so world geometry lands where the view says', async () => {
51
+ const draw = vi.fn();
52
+ render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
53
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
54
+
55
+ expect(draw).toHaveBeenCalled();
56
+ expect(ctxSpies.translate).toHaveBeenCalledWith(30, 40);
57
+ expect(ctxSpies.scale).toHaveBeenCalledWith(2, 2);
58
+ });
59
+
60
+ it('passes zoom in the args so a layer can keep line widths unscaled', async () => {
61
+ const draw = vi.fn();
62
+ render(<Lab instruments={[makeInstrument(draw)]} defaultInstrument="Probe" />);
63
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
64
+
65
+ expect(draw).toHaveBeenCalledWith(
66
+ expect.anything(),
67
+ expect.objectContaining({ zoom: 2, state: { n: 7 } }),
68
+ );
69
+ });
70
+ });
71
+
72
+ describe('LabProps.instruments', () => {
73
+ it('accepts a typed instrument with no cast', () => {
74
+ // The assignment is the assertion: this file fails to compile if
75
+ // `defineInstrument<St, ...>`'s result stops being assignable.
76
+ const typed = makeInstrument(() => undefined);
77
+ render(<Lab instruments={[typed]} defaultInstrument="Probe" />);
78
+ expect(screen.getByTestId('readout')).toBeInTheDocument();
79
+ });
80
+ });
@@ -0,0 +1,75 @@
1
+ import { render, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { defineInstrument } from '../instrument/defineInstrument';
5
+ import type { JobEvent } from '../job/types';
6
+ import { Lab } from '../lab/Lab';
7
+
8
+ interface S {
9
+ items: number[];
10
+ }
11
+ interface C {
12
+ n: number;
13
+ }
14
+
15
+ function makeInstrument(name: string, n: number, delay: number) {
16
+ return defineInstrument<S, C>({
17
+ name,
18
+ defaultConfig: () => ({ n }),
19
+ initialState: () => ({ items: [] }),
20
+ render: () => null,
21
+ job: {
22
+ auto: true,
23
+ run: async function* ({ config, signal }): AsyncGenerator<JobEvent<never>> {
24
+ yield { kind: 'total', total: config.n };
25
+ for (let i = 0; i < config.n; i++) {
26
+ await new Promise((r) => setTimeout(r, delay));
27
+ if (signal.aborted) return;
28
+ yield { kind: 'item', item: i as never };
29
+ }
30
+ },
31
+ onItem: (item, state) => ({ items: [...state.items, item as number] }),
32
+ },
33
+ });
34
+ }
35
+
36
+ describe('a trial whose instrument declares a job', () => {
37
+ it('shows progress in the trial chrome', async () => {
38
+ const fast = makeInstrument('fast', 3, 1);
39
+ render(<Lab instruments={[fast]} defaultInstrument="fast" storage={null} />);
40
+ await waitFor(() => {
41
+ expect(document.querySelector('.lk-trial__job')).toBeInTheDocument();
42
+ });
43
+ await waitFor(() => {
44
+ expect(document.querySelector('.lk-trial__job-count')?.textContent).toMatch(/3\s*\/\s*3/);
45
+ });
46
+ });
47
+
48
+ it('offers a cancel control while running, and drops it once cancelled', async () => {
49
+ const slow = makeInstrument('slow', 40, 20);
50
+ render(<Lab instruments={[slow]} defaultInstrument="slow" storage={null} />);
51
+ const cancel = await waitFor(() => {
52
+ const el = document.querySelector('.lk-trial__job-cancel');
53
+ expect(el).toBeInTheDocument();
54
+ return el as HTMLElement;
55
+ });
56
+ await userEvent.click(cancel);
57
+ await waitFor(() => {
58
+ expect(document.querySelector('.lk-trial__job-cancel')).not.toBeInTheDocument();
59
+ });
60
+ });
61
+
62
+ it('renders no job chrome for an instrument that declares none', async () => {
63
+ const plain = defineInstrument<S, C>({
64
+ name: 'plain',
65
+ defaultConfig: () => ({ n: 0 }),
66
+ initialState: () => ({ items: [] }),
67
+ render: () => null,
68
+ });
69
+ render(<Lab instruments={[plain]} defaultInstrument="plain" storage={null} />);
70
+ await waitFor(() => {
71
+ expect(document.querySelector('.lk-trial')).toBeInTheDocument();
72
+ });
73
+ expect(document.querySelector('.lk-trial__job')).not.toBeInTheDocument();
74
+ });
75
+ });
@@ -1,4 +1,4 @@
1
- .lk-workspace {
1
+ .lk-trial {
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  height: 100%;
@@ -56,3 +56,15 @@
56
56
  color: var(--wzl-fg-muted);
57
57
  font-size: var(--wzl-font-size-sm);
58
58
  }
59
+
60
+ .lk-trial__job {
61
+ display: flex;
62
+ gap: 8px;
63
+ align-items: center;
64
+ font-size: 0.75rem;
65
+ }
66
+
67
+ .lk-trial__job-failures,
68
+ .lk-trial__job-error {
69
+ color: var(--wsl-color-danger, #c0392b);
70
+ }