@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,149 @@
1
+ import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
2
+
3
+ /** A point in the space the instrument works in. */
4
+ export interface Vec3 {
5
+ x: number;
6
+ y: number;
7
+ z: number;
8
+ }
9
+
10
+ /** An orbit camera as a trial view: where it looks from, and what it looks at.
11
+ * labkit does not turn this into a matrix — the host's renderer does. */
12
+ export interface OrbitView {
13
+ yaw: number;
14
+ pitch: number;
15
+ distance: number;
16
+ target: Vec3;
17
+ }
18
+
19
+ /** Just short of the pole. At exactly ±PI/2 the azimuth is undefined and the
20
+ * camera rolls, which reads as the model jumping rather than as a limit. */
21
+ export const PITCH_LIMIT = Math.PI / 2 - 0.01;
22
+
23
+ const YAW_PER_PX = 0.008;
24
+ const PITCH_PER_PX = 0.008;
25
+ const DISTANCE_PER_NOTCH = 0.0015;
26
+ const DRAG_THRESHOLD = 3;
27
+
28
+ export function clampPitch(pitch: number): number {
29
+ return Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, pitch));
30
+ }
31
+
32
+ export function wrapYaw(yaw: number): number {
33
+ const wrapped = ((((yaw + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
34
+ return wrapped === -Math.PI ? Math.PI : wrapped;
35
+ }
36
+
37
+ /** The view a drag of (dx, dy) from `start` produces. Absolute against the drag
38
+ * start, so it can be re-applied any number of times without compounding. */
39
+ export function orbitAfterDrag(start: OrbitView, dx: number, dy: number): OrbitView {
40
+ return {
41
+ ...start,
42
+ yaw: wrapYaw(start.yaw + dx * YAW_PER_PX),
43
+ pitch: clampPitch(start.pitch + dy * PITCH_PER_PX),
44
+ };
45
+ }
46
+
47
+ /** Multiplicative, so one notch covers the same proportion of the distance
48
+ * whether the camera is near or far. */
49
+ export function orbitAfterWheel(
50
+ view: OrbitView,
51
+ deltaY: number,
52
+ minDistance: number,
53
+ maxDistance: number,
54
+ ): OrbitView {
55
+ const factor = Math.exp(deltaY * DISTANCE_PER_NOTCH);
56
+ return {
57
+ ...view,
58
+ distance: Math.min(maxDistance, Math.max(minDistance, view.distance * factor)),
59
+ };
60
+ }
61
+
62
+ export interface UseOrbitOptions {
63
+ view: OrbitView;
64
+ onViewChange: (v: OrbitView) => void;
65
+ /** Restored on double-click. Defaults to the view the hook first saw. */
66
+ home?: OrbitView;
67
+ minDistance?: number;
68
+ maxDistance?: number;
69
+ }
70
+
71
+ export interface OrbitHandlers {
72
+ onWheel: (e: WheelEvent<HTMLElement>) => void;
73
+ onPointerDown: (e: PointerEvent<HTMLElement>) => void;
74
+ onPointerMove: (e: PointerEvent<HTMLElement>) => void;
75
+ onPointerUp: (e: PointerEvent<HTMLElement>) => void;
76
+ onDoubleClick: () => void;
77
+ isDragging: () => boolean;
78
+ }
79
+
80
+ interface DragState {
81
+ pointerId: number;
82
+ startX: number;
83
+ startY: number;
84
+ startView: OrbitView;
85
+ moved: boolean;
86
+ }
87
+
88
+ /** Pointer gestures over an orbit view: drag to turn, wheel or pinch to dolly,
89
+ * double-click to go home. The 3D peer of `usePanZoom`. */
90
+ export function useOrbit({
91
+ view,
92
+ onViewChange,
93
+ home,
94
+ minDistance = 0.1,
95
+ maxDistance = 1000,
96
+ }: UseOrbitOptions): OrbitHandlers {
97
+ const dragRef = useRef<DragState | null>(null);
98
+ const viewRef = useRef(view);
99
+ viewRef.current = view;
100
+ const homeRef = useRef(home ?? view);
101
+ if (home) homeRef.current = home;
102
+
103
+ const onWheel = useCallback(
104
+ (e: WheelEvent<HTMLElement>) => {
105
+ e.preventDefault();
106
+ onViewChange(orbitAfterWheel(viewRef.current, e.deltaY, minDistance, maxDistance));
107
+ },
108
+ [onViewChange, minDistance, maxDistance],
109
+ );
110
+
111
+ const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
112
+ if (e.button !== 0) return;
113
+ e.currentTarget.setPointerCapture(e.pointerId);
114
+ dragRef.current = {
115
+ pointerId: e.pointerId,
116
+ startX: e.clientX,
117
+ startY: e.clientY,
118
+ startView: viewRef.current,
119
+ moved: false,
120
+ };
121
+ }, []);
122
+
123
+ const onPointerMove = useCallback(
124
+ (e: PointerEvent<HTMLElement>) => {
125
+ const drag = dragRef.current;
126
+ if (!drag || drag.pointerId !== e.pointerId) return;
127
+ const dx = e.clientX - drag.startX;
128
+ const dy = e.clientY - drag.startY;
129
+ if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
130
+ drag.moved = true;
131
+ onViewChange(orbitAfterDrag(drag.startView, dx, dy));
132
+ },
133
+ [onViewChange],
134
+ );
135
+
136
+ const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
137
+ const drag = dragRef.current;
138
+ if (!drag || drag.pointerId !== e.pointerId) return;
139
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
140
+ e.currentTarget.releasePointerCapture(e.pointerId);
141
+ }
142
+ dragRef.current = null;
143
+ }, []);
144
+
145
+ const onDoubleClick = useCallback(() => onViewChange(homeRef.current), [onViewChange]);
146
+ const isDragging = useCallback(() => dragRef.current?.moved === true, []);
147
+
148
+ return { onWheel, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, isDragging };
149
+ }
@@ -34,19 +34,16 @@ const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRec
34
34
  function StateProbe({ onState }: { onState: (state: DropState) => void }) {
35
35
  const ctx = useContext(LabStoreContext);
36
36
  const ws = useStore(
37
- ctx?.store ?? ({ getState: () => ({ workspaces: [] }) } as never),
38
- (s) => (s as { workspaces: { state: unknown }[] }).workspaces[0],
37
+ ctx?.store ?? ({ getState: () => ({ trials: [] }) } as never),
38
+ (s) => (s as { trials: { state: unknown }[] }).trials[0],
39
39
  );
40
40
  if (ws) onState(ws.state as DropState);
41
41
  return null;
42
42
  }
43
43
 
44
- // biome-ignore lint/suspicious/noExplicitAny: cross-generic instrument array
45
- const instruments: any[] = [testInstrument];
46
-
47
44
  function renderLab(probe?: (state: DropState) => void) {
48
45
  return render(
49
- <Lab instruments={instruments} defaultInstrument="TestDrop">
46
+ <Lab instruments={[testInstrument]} defaultInstrument="TestDrop">
50
47
  {probe ? <StateProbe onState={probe} /> : null}
51
48
  </Lab>,
52
49
  );
@@ -54,7 +51,7 @@ function renderLab(probe?: (state: DropState) => void) {
54
51
 
55
52
  beforeEach(() => {
56
53
  HTMLElement.prototype.getBoundingClientRect = function () {
57
- if (this.classList.contains('lk-workspace__canvas-host')) {
54
+ if (this.classList.contains('lk-trial__canvas-host')) {
58
55
  return {
59
56
  x: 100,
60
57
  y: 100,
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+ import * as labkit from './index';
5
+
6
+ /** The barrels the public surface is assembled from. Type-only exports are
7
+ * erased at runtime, so the names are read out of the source rather than off
8
+ * the module object. */
9
+ const BARRELS = ['./index.ts', './lab/index.ts', './trial/index.ts', './state/index.ts'];
10
+
11
+ function exportedNames(): string[] {
12
+ const names: string[] = [];
13
+ for (const barrel of BARRELS) {
14
+ const src = readFileSync(fileURLToPath(new URL(barrel, import.meta.url)), 'utf8');
15
+ for (const [, body] of src.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
16
+ for (const clause of body.split(',')) {
17
+ const name = clause
18
+ .trim()
19
+ .split(/\s+as\s+/)
20
+ .pop()
21
+ ?.replace(/^type\s+/, '')
22
+ .trim();
23
+ if (name) names.push(name);
24
+ }
25
+ }
26
+ }
27
+ return names;
28
+ }
29
+
30
+ describe('public export surface', () => {
31
+ it('names the grid, not a tile, when it says Workspace', () => {
32
+ const offenders = exportedNames().filter(
33
+ (n) => n.startsWith('Workspace') && n !== 'Workspace' && n !== 'WorkspaceProps',
34
+ );
35
+ expect(offenders).toEqual([]);
36
+ });
37
+
38
+ it('exports no Experiment symbol that means anything per-tile', () => {
39
+ const offenders = exportedNames().filter(
40
+ (n) => n.includes('Experiment') && !n.startsWith('SingletonExperiment'),
41
+ );
42
+ expect(offenders).toEqual([]);
43
+ });
44
+
45
+ it('exports the tile as Trial and the area it sits in as Workspace', () => {
46
+ expect(labkit).toHaveProperty('Trial');
47
+ expect(labkit).toHaveProperty('TrialChrome');
48
+ expect(labkit).toHaveProperty('Workspace');
49
+ expect(labkit).toHaveProperty('useTrialState');
50
+ expect(labkit).toHaveProperty('useTrialId');
51
+ });
52
+ });
53
+
54
+ describe('surface, job and orbit entry points', () => {
55
+ it('are reachable from the package root', async () => {
56
+ const kit = await import('./index');
57
+ expect(typeof kit.useTiledSurface).toBe('function');
58
+ expect(typeof kit.useSurfaceTile).toBe('function');
59
+ expect(typeof kit.useSurface).toBe('function');
60
+ expect(typeof kit.useSurfaceOptional).toBe('function');
61
+ expect(typeof kit.toDeviceRect).toBe('function');
62
+ expect(typeof kit.composeRects).toBe('function');
63
+ expect(typeof kit.useOrbit).toBe('function');
64
+ expect(typeof kit.orbitAfterDrag).toBe('function');
65
+ expect(typeof kit.useJob).toBe('function');
66
+ expect(typeof kit.as2DView).toBe('function');
67
+ expect(kit.SurfaceContext).toBeDefined();
68
+ expect(kit.DEFAULT_VIEW).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
69
+ });
70
+ });
package/src/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  export type { CanvasLayerDescriptor, CanvasStackContextValue, CanvasStackProps } from './canvas';
2
2
  export { CanvasStack, CanvasStackContext, screenToWorld, worldToScreen } from './canvas';
3
+ export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './canvas/useOrbit';
4
+ export {
5
+ clampPitch,
6
+ orbitAfterDrag,
7
+ orbitAfterWheel,
8
+ PITCH_LIMIT,
9
+ useOrbit,
10
+ wrapYaw,
11
+ } from './canvas/useOrbit';
3
12
  export { ControlPanel } from './controls/ControlPanel';
4
13
  export type {
5
14
  CheckboxField,
@@ -23,6 +32,7 @@ export type {
23
32
  DragFeedback,
24
33
  HitResult,
25
34
  Instrument,
35
+ InstrumentList,
26
36
  LayerCapability,
27
37
  LayerDescriptor,
28
38
  PaletteItem,
@@ -34,6 +44,7 @@ export type {
34
44
  } from './instrument/types';
35
45
  export type { ValidationResult } from './instrument/validateConfigSchema';
36
46
  export { validateConfigSchema } from './instrument/validateConfigSchema';
47
+ export * from './job';
37
48
  export * from './lab';
38
49
  export type { LayerListProps } from './layers';
39
50
  export { LayerList } from './layers';
@@ -48,25 +59,31 @@ export {
48
59
  export {
49
60
  LabStoreContext,
50
61
  LabStoreProvider,
62
+ TrialIdContext,
63
+ TrialIdProvider,
51
64
  useLabStore,
52
- useWorkspaceId,
53
- WorkspaceIdContext,
54
- WorkspaceIdProvider,
65
+ useTrialId,
55
66
  } from './state/context';
67
+ export { CURRENT_DOCUMENT_VERSION, labDocumentKey, quarantineKey } from './state/document';
56
68
  export type {
57
69
  CreateLabStoreOptions,
58
- ExperimentStateHandle,
70
+ LabDocument,
59
71
  LabMode,
60
72
  LabStoreState,
61
73
  SavedSnapshot,
74
+ SerializedTrial,
62
75
  StorageAdapter,
76
+ TrialRecord,
77
+ TrialStateHandle,
63
78
  UndoStack,
64
- WorkspaceRecord,
65
79
  } from './state/types';
66
- export { useExperimentState } from './state/useExperimentState';
80
+ export { useTrialState } from './state/useTrialState';
81
+ export type { ViewTransform2D } from './state/view';
82
+ export { as2DView, DEFAULT_VIEW } from './state/view';
83
+ export * from './surface';
67
84
  export { interstellarTheme } from './theme/interstellar';
85
+ export * from './trial';
68
86
  export * from './ui/layers';
69
87
  export * from './ui/properties';
70
88
  export type { EventBus, EventListener } from './undo';
71
89
  export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from './undo';
72
- export * from './workspace';
@@ -1,6 +1,7 @@
1
1
  import { render } from '@testing-library/react';
2
2
  import type { ReactElement } from 'react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
+ import { DEFAULT_VIEW } from '../state/view';
4
5
  import { defineInstrument } from './defineInstrument';
5
6
  import type { RenderContext } from './types';
6
7
  import { validateConfigSchema } from './validateConfigSchema';
@@ -85,7 +86,7 @@ describe('SineWave instrument smoke test', () => {
85
86
  config: SineWaveInstrument.defaultConfig(),
86
87
  setState: vi.fn(),
87
88
  setConfig: vi.fn(),
88
- workspace: { id: 'w', zoom: 1, setZoom: vi.fn() },
89
+ trial: { id: 'w', view: DEFAULT_VIEW, setView: vi.fn(), zoom: 1, setZoom: vi.fn() },
89
90
  emit: vi.fn(),
90
91
  };
91
92
  const { container } = render(SineWaveInstrument.render(ctx) as ReactElement);
@@ -1,6 +1,6 @@
1
1
  import type { Instrument } from './types';
2
2
 
3
- /** Which optional capabilities an instrument declared — what the workspace
3
+ /** Which optional capabilities an instrument declared — what the trial
4
4
  * consults to decide which chrome to show. */
5
5
  export interface CapabilityFlags {
6
6
  hasCanvas: boolean;
@@ -1,22 +1,35 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { ConfigField } from '../controls/types';
3
+ import type { JobCapability, JobHandle } from '../job/types';
3
4
 
4
5
  /** What an instrument's `render` is handed: its state and config, the setters
5
- * for both, the workspace it is mounted in, and a way to emit named events. */
6
+ * for both, the trial it is mounted in, and a way to emit named events. */
6
7
  export interface RenderContext<TS = unknown, TC = unknown> {
7
8
  state: TS;
8
9
  config: TC;
9
10
  setState: (next: TS | ((prev: TS) => TS)) => void;
10
11
  setConfig: (key: keyof TC, value: unknown) => void;
11
- workspace: {
12
+ trial: {
12
13
  id: string;
14
+ /** The trial's view, in whatever shape this instrument chose. labkit persists
15
+ * it and restores it on Reset without ever reading into it. */
16
+ view: unknown;
17
+ setView: (next: unknown) => void;
18
+ /** 2D convenience over `view`. Reads 1 and writes nothing when the trial holds
19
+ * a view that is not the 2D one. */
13
20
  zoom: number;
14
21
  setZoom: (z: number) => void;
15
22
  };
16
23
  emit: (event: string) => void;
24
+ /** Present only when the instrument declares a `job`. */
25
+ job?: JobHandle;
17
26
  }
18
27
 
19
- /** One 2D canvas layer of an instrument, drawn in declaration order. */
28
+ /** One 2D canvas layer of an instrument, drawn in declaration order.
29
+ *
30
+ * `draw` is called with the camera already applied, so it works in world
31
+ * coordinates. `zoom` is passed for the things that must not scale with it —
32
+ * set `ctx.lineWidth = 1 / zoom` to keep a hairline hairline. */
20
33
  export interface CanvasLayer<TS = unknown, TC = unknown> {
21
34
  id: string;
22
35
  draw: (ctx: CanvasRenderingContext2D, args: { state: TS; config: TC; zoom: number }) => void;
@@ -29,7 +42,7 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
29
42
  initialView?: { zoom: number; pan: { x: number; y: number } };
30
43
  }
31
44
 
32
- /** Declares which of an instrument's layers the workspace should offer
45
+ /** Declares which of an instrument's layers the trial should offer
33
46
  * show/hide controls for. */
34
47
  export interface LayerCapability {
35
48
  ids: string[];
@@ -59,7 +72,7 @@ export type SystemEvent = string;
59
72
  export type Point = { x: number; y: number };
60
73
  /** What a hit-test found, and where. */
61
74
  export type HitResult = { hit: boolean; layerId?: string; pointId?: string };
62
- /** A workspace's camera. */
75
+ /** A trial's camera. */
63
76
  export type ViewTransform = { zoom: number; pan: Point };
64
77
  /** A layer as the layer list shows it. `alwaysOn` layers cannot be hidden. */
65
78
  export type LayerDescriptor = { id: string; label: string; alwaysOn?: boolean };
@@ -76,13 +89,15 @@ export type DragFeedback = { ok: boolean; reason?: string };
76
89
  * and `state`, what the experiment is currently doing — and renders from both.
77
90
  * The optional capability fields declare what else it wants from the runtime:
78
91
  * a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
79
- * is what makes the workspace provide the corresponding chrome.
92
+ * is what makes the trial provide the corresponding chrome.
80
93
  */
81
94
  export interface Instrument<TS = unknown, TC = unknown> {
82
95
  name: string;
83
96
  defaultConfig: () => TC;
84
97
  initialState: (config: TC) => TS;
85
98
  configSchema?: () => ConfigField[];
99
+ /** The instrument's DOM. With `canvas`, this renders as an overlay above the
100
+ * layers rather than instead of them; return `null` for canvas only. */
86
101
  render: (ctx: RenderContext<TS, TC>) => ReactNode;
87
102
  onConfigChange?: (config: TC, prev: TC, state: TS) => TS;
88
103
  serialize?: (state: TS) => unknown;
@@ -91,4 +106,13 @@ export interface Instrument<TS = unknown, TC = unknown> {
91
106
  layers?: LayerCapability;
92
107
  dragDrop?: DragDropCapability<TS, TC>;
93
108
  undo?: UndoCapability;
109
+ /** Work too slow to do during a render. The runtime starts it, aborts it on
110
+ * unmount and on a `key` change, and renders progress into the trial. */
111
+ job?: JobCapability<TS, TC, never>;
94
112
  }
113
+
114
+ /** Instruments as a lab receives them. `any` rather than `unknown` because
115
+ * parameter contravariance keeps a `defineInstrument<TS, TC>` result out of
116
+ * an `Instrument<unknown, unknown>[]`; it is contained to this alias. */
117
+ // biome-ignore lint/suspicious/noExplicitAny: see above
118
+ export type InstrumentList = readonly Instrument<any, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- contravariant TC; see above
@@ -0,0 +1,3 @@
1
+ export type { JobCapability, JobEvent, JobFailure, JobHandle, JobStatus } from './types';
2
+ export type { UseJobOptions } from './useJob';
3
+ export { useJob } from './useJob';
@@ -0,0 +1,47 @@
1
+ /** What a running job reports as it goes. `total` may arrive at any point and may
2
+ * arrive more than once; a job that cannot count up front simply never sends it.
3
+ *
4
+ * `failed` is a first-class event rather than a thrown error because these
5
+ * failures are per item: a run with two failed items is a partial success, and
6
+ * its other items are worth showing. */
7
+ export type JobEvent<T> =
8
+ | { kind: 'total'; total: number }
9
+ | { kind: 'item'; item: T }
10
+ | { kind: 'failed'; index: number; error: string };
11
+
12
+ /** Where a job is. `idle` before its first run and after a cancel; `done` when the
13
+ * iterable finished, whether or not items failed along the way. */
14
+ export type JobStatus = 'idle' | 'running' | 'done' | 'error';
15
+
16
+ /** Declares that an instrument has work too slow to do during a render: what to
17
+ * run, when to re-run it, and how each result folds into state. */
18
+ export interface JobCapability<TS = unknown, TC = unknown, TItem = unknown> {
19
+ /** Re-run whenever this value changes, compared element-wise. A job with no
20
+ * `key` runs only when something calls `start()`. */
21
+ key?: (config: TC, state: TS) => readonly unknown[];
22
+ /** Start on mount and on every `key` change. Default false. */
23
+ auto?: boolean;
24
+ run: (args: { config: TC; state: TS; signal: AbortSignal }) => AsyncIterable<JobEvent<TItem>>;
25
+ /** Fold one result into state. Called once per `item` event, in arrival order. */
26
+ onItem: (item: TItem, state: TS) => TS;
27
+ }
28
+
29
+ /** One item that failed, and why. */
30
+ export interface JobFailure {
31
+ index: number;
32
+ error: string;
33
+ }
34
+
35
+ /** What `RenderContext.job` exposes. Present only when the instrument declares the
36
+ * capability; `undefined` otherwise. */
37
+ export interface JobHandle {
38
+ status: JobStatus;
39
+ done: number;
40
+ /** Null until the job reports a total, and forever if it never does. */
41
+ total: number | null;
42
+ failures: readonly JobFailure[];
43
+ /** The error that ended the run, when `status` is `'error'`. */
44
+ error: string | null;
45
+ start: () => void;
46
+ cancel: () => void;
47
+ }