@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
@@ -8,18 +8,18 @@ export type {
8
8
  SidebarSlot,
9
9
  StatusBarSlot,
10
10
  ToolbarSlot,
11
- WorkspaceSidebarContext,
12
- WorkspaceStatusBarContext,
13
- WorkspaceToolbarContext,
11
+ TrialSidebarContext,
12
+ TrialStatusBarContext,
13
+ TrialToolbarContext,
14
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';
15
+ export type { TrialProps } from './Trial';
16
+ export { Trial } from './Trial';
17
+ export type { TrialChromeProps } from './TrialChrome';
18
+ export { TrialChrome } from './TrialChrome';
19
19
  export {
20
- addWorkspace,
21
- cloneWorkspace,
22
- closeWorkspace,
23
- reorderWorkspaces,
24
- resetWorkspace,
25
- } from './workspaceOps';
20
+ addTrial,
21
+ cloneTrial,
22
+ closeTrial,
23
+ reorderTrials,
24
+ resetTrial,
25
+ } from './trialOps';
@@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
2
2
  import type { ConfigField } from '../controls/types';
3
3
  import type { SavedSnapshot } from '../state/types';
4
4
 
5
- /** What a workspace hands its toolbar: which instrument is running, the undo
5
+ /** What a trial hands its toolbar: which instrument is running, the undo
6
6
  * state and commands, the zoom controls, and the snapshot commands. */
7
- export interface WorkspaceToolbarContext {
8
- workspaceId: string;
7
+ export interface TrialToolbarContext {
8
+ trialId: string;
9
9
  instrumentName: string;
10
10
  hasUndo: boolean;
11
11
  canUndo: boolean;
@@ -24,30 +24,31 @@ export interface WorkspaceToolbarContext {
24
24
  clone: () => void;
25
25
  reset: () => void;
26
26
  close: () => void;
27
- isLastWorkspace: boolean;
27
+ isLastTrial: boolean;
28
28
  }
29
29
 
30
- /** What a workspace hands its sidebar: the instrument's config schema, its
30
+ /** What a trial hands its sidebar: the instrument's config schema, its
31
31
  * current values, and the setter. */
32
- export interface WorkspaceSidebarContext {
33
- workspaceId: string;
32
+ export interface TrialSidebarContext {
33
+ trialId: string;
34
34
  instrumentName: string;
35
35
  configFields: ConfigField[];
36
36
  config: unknown;
37
37
  setConfig: (key: string, value: unknown) => void;
38
38
  }
39
39
 
40
- /** What a workspace hands its status bar. */
41
- export interface WorkspaceStatusBarContext {
42
- workspaceId: string;
40
+ /** What a trial hands its status bar. */
41
+ export interface TrialStatusBarContext {
42
+ trialId: string;
43
43
  instrumentName: string;
44
- zoom: number;
44
+ /** Null when the trial holds a view that is not the 2D one. */
45
+ zoom: number | null;
45
46
  }
46
47
 
47
- /** Replaces a workspace's toolbar. Receives everything the default one uses,
48
+ /** Replaces a trial's toolbar. Receives everything the default one uses,
48
49
  * so a custom toolbar need not reach into the store. */
49
- export type ToolbarSlot = (ctx: WorkspaceToolbarContext) => ReactNode;
50
- /** Replaces a workspace's sidebar. */
51
- export type SidebarSlot = (ctx: WorkspaceSidebarContext) => ReactNode;
52
- /** Replaces a workspace's status bar. */
53
- export type StatusBarSlot = (ctx: WorkspaceStatusBarContext) => ReactNode;
50
+ export type ToolbarSlot = (ctx: TrialToolbarContext) => ReactNode;
51
+ /** Replaces a trial's sidebar. */
52
+ export type SidebarSlot = (ctx: TrialSidebarContext) => ReactNode;
53
+ /** Replaces a trial's status bar. */
54
+ export type StatusBarSlot = (ctx: TrialStatusBarContext) => ReactNode;
@@ -1,13 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Instrument } from '../instrument/types';
3
- import type { WorkspaceRecord } from '../state/types';
4
- import {
5
- addWorkspace,
6
- cloneWorkspace,
7
- closeWorkspace,
8
- reorderWorkspaces,
9
- resetWorkspace,
10
- } from './workspaceOps';
3
+ import type { TrialRecord } from '../state/types';
4
+ import { addTrial, cloneTrial, closeTrial, reorderTrials, resetTrial } from './trialOps';
11
5
 
12
6
  interface CounterState {
13
7
  count: number;
@@ -40,9 +34,9 @@ function head<T>(arr: T[]): T {
40
34
  return first;
41
35
  }
42
36
 
43
- describe('addWorkspace', () => {
37
+ describe('addTrial', () => {
44
38
  it('appends a new record using defaultConfig and initialState', () => {
45
- const out = addWorkspace([], instruments, 'Counter');
39
+ const out = addTrial([], instruments, 'Counter');
46
40
  expect(out).toHaveLength(1);
47
41
  expect(out[0]?.instrumentName).toBe('Counter');
48
42
  expect(out[0]?.config).toEqual({ step: 1 });
@@ -50,38 +44,38 @@ describe('addWorkspace', () => {
50
44
  });
51
45
 
52
46
  it('assigns a unique id', () => {
53
- const a = addWorkspace([], instruments, 'Counter');
54
- const b = addWorkspace(a, instruments, 'Counter');
47
+ const a = addTrial([], instruments, 'Counter');
48
+ const b = addTrial(a, instruments, 'Counter');
55
49
  expect(b).toHaveLength(2);
56
50
  expect(b[0]?.id).not.toBe(b[1]?.id);
57
51
  });
58
52
 
59
53
  it('uses canvas.initialView when present', () => {
60
- const out = addWorkspace([], instruments, 'CounterCanvas');
54
+ const out = addTrial([], instruments, 'CounterCanvas');
61
55
  expect(out[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
62
56
  });
63
57
 
64
58
  it('defaults view when canvas absent', () => {
65
- const out = addWorkspace([], instruments, 'Counter');
59
+ const out = addTrial([], instruments, 'Counter');
66
60
  expect(out[0]?.view).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
67
61
  });
68
62
 
69
63
  it('throws when instrumentName is unknown', () => {
70
- expect(() => addWorkspace([], instruments, 'Missing')).toThrow();
64
+ expect(() => addTrial([], instruments, 'Missing')).toThrow();
71
65
  });
72
66
 
73
67
  it('does not mutate input array', () => {
74
- const arr: WorkspaceRecord[] = [];
75
- addWorkspace(arr, instruments, 'Counter');
68
+ const arr: TrialRecord[] = [];
69
+ addTrial(arr, instruments, 'Counter');
76
70
  expect(arr).toHaveLength(0);
77
71
  });
78
72
  });
79
73
 
80
- describe('cloneWorkspace', () => {
74
+ describe('cloneTrial', () => {
81
75
  it('inserts clone immediately after source with new id', () => {
82
- const a = addWorkspace([], instruments, 'Counter');
76
+ const a = addTrial([], instruments, 'Counter');
83
77
  const sourceId = head(a).id;
84
- const cloned = cloneWorkspace(a, sourceId);
78
+ const cloned = cloneTrial(a, sourceId);
85
79
  expect(cloned).toHaveLength(2);
86
80
  expect(cloned[0]?.id).toBe(sourceId);
87
81
  expect(cloned[1]?.id).not.toBe(sourceId);
@@ -89,65 +83,65 @@ describe('cloneWorkspace', () => {
89
83
  });
90
84
 
91
85
  it('deep-copies config/state/view', () => {
92
- const a = addWorkspace([], instruments, 'Counter');
93
- const cloned = cloneWorkspace(a, head(a).id);
86
+ const a = addTrial([], instruments, 'Counter');
87
+ const cloned = cloneTrial(a, head(a).id);
94
88
  expect(cloned[1]?.config).not.toBe(cloned[0]?.config);
95
89
  expect(cloned[1]?.state).not.toBe(cloned[0]?.state);
96
90
  expect(cloned[1]?.view).not.toBe(cloned[0]?.view);
97
91
  });
98
92
 
99
93
  it('does not mutate input', () => {
100
- const a = addWorkspace([], instruments, 'Counter');
94
+ const a = addTrial([], instruments, 'Counter');
101
95
  const before = a.length;
102
- cloneWorkspace(a, head(a).id);
96
+ cloneTrial(a, head(a).id);
103
97
  expect(a).toHaveLength(before);
104
98
  });
105
99
  });
106
100
 
107
- describe('closeWorkspace', () => {
108
- it('removes the workspace with the given id', () => {
109
- let arr = addWorkspace([], instruments, 'Counter');
110
- arr = addWorkspace(arr, instruments, 'Counter');
111
- const closed = closeWorkspace(arr, head(arr).id);
101
+ describe('closeTrial', () => {
102
+ it('removes the trial with the given id', () => {
103
+ let arr = addTrial([], instruments, 'Counter');
104
+ arr = addTrial(arr, instruments, 'Counter');
105
+ const closed = closeTrial(arr, head(arr).id);
112
106
  expect(closed).toHaveLength(1);
113
107
  expect(closed[0]?.id).toBe(arr[1]?.id);
114
108
  });
115
109
 
116
- it('is a no-op when only one workspace remains', () => {
117
- const arr = addWorkspace([], instruments, 'Counter');
118
- const closed = closeWorkspace(arr, head(arr).id);
110
+ it('is a no-op when only one trial remains', () => {
111
+ const arr = addTrial([], instruments, 'Counter');
112
+ const closed = closeTrial(arr, head(arr).id);
119
113
  expect(closed).toEqual(arr);
120
114
  });
121
115
  });
122
116
 
123
- describe('resetWorkspace', () => {
117
+ describe('resetTrial', () => {
124
118
  it('resets config and state to defaults; preserves instrumentName', () => {
125
- const arr = addWorkspace([], instruments, 'Counter');
119
+ const arr = addTrial([], instruments, 'Counter');
126
120
  const id = head(arr).id;
127
121
  head(arr).config = { step: 99 } as CounterConfig;
128
122
  head(arr).state = { count: 42 } as CounterState;
129
- const reset = resetWorkspace(arr, id, instruments);
123
+ const reset = resetTrial(arr, id, instruments);
130
124
  expect(reset[0]?.config).toEqual({ step: 1 });
131
125
  expect(reset[0]?.state).toEqual({ count: 0 });
132
126
  expect(reset[0]?.instrumentName).toBe('Counter');
133
127
  });
134
128
 
135
129
  it('resets view to canvas.initialView when present', () => {
136
- const arr = addWorkspace([], instruments, 'CounterCanvas');
130
+ const arr = addTrial([], instruments, 'CounterCanvas');
137
131
  head(arr).view = { zoom: 9, pan: { x: 9, y: 9 } };
138
- const reset = resetWorkspace(arr, head(arr).id, instruments);
132
+ const reset = resetTrial(arr, head(arr).id, instruments);
139
133
  expect(reset[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
140
134
  });
141
135
 
142
136
  it('does not mutate input', () => {
143
- const arr = addWorkspace([], instruments, 'Counter');
137
+ const arr = addTrial([], instruments, 'Counter');
144
138
  head(arr).config = { step: 99 } as CounterConfig;
145
- resetWorkspace(arr, head(arr).id, instruments);
139
+ resetTrial(arr, head(arr).id, instruments);
146
140
  expect((arr[0]?.config as CounterConfig).step).toBe(99);
147
141
  });
148
142
  });
149
143
 
150
- describe('reorderWorkspaces', () => {
144
+ describe('reorderTrials', () => {
151
145
  const ws = (id: string) =>
152
146
  ({
153
147
  id,
@@ -159,17 +153,17 @@ describe('reorderWorkspaces', () => {
159
153
  }) as never;
160
154
 
161
155
  it('reorders to match the given ids', () => {
162
- const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
156
+ const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
163
157
  expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
164
158
  });
165
159
 
166
160
  it('drops ids that no longer exist rather than resurrecting them', () => {
167
- const next = reorderWorkspaces([ws('a'), ws('b')], ['gone', 'b', 'a']);
161
+ const next = reorderTrials([ws('a'), ws('b')], ['gone', 'b', 'a']);
168
162
  expect(next.map((w) => w.id)).toEqual(['b', 'a']);
169
163
  });
170
164
 
171
- it('keeps unmentioned workspaces, after the named ones', () => {
172
- const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c']);
165
+ it('keeps unmentioned trials, after the named ones', () => {
166
+ const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c']);
173
167
  expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
174
168
  });
175
169
  });
@@ -0,0 +1,99 @@
1
+ import type { Instrument, InstrumentList } from '../instrument/types';
2
+ import type { TrialRecord } from '../state/types';
3
+
4
+ const DEFAULT_VIEW = { zoom: 1, pan: { x: 0, y: 0 } } as const;
5
+
6
+ function findInstrument(instruments: InstrumentList, 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): TrialRecord['view'] {
15
+ return instrument.canvas?.initialView
16
+ ? structuredClone(instrument.canvas.initialView)
17
+ : { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
18
+ }
19
+
20
+ /** Append a new trial running `instrumentName`, at that instrument's
21
+ * default config and initial state. */
22
+ export function addTrial(
23
+ trials: TrialRecord[],
24
+ instruments: InstrumentList,
25
+ instrumentName: string,
26
+ ): TrialRecord[] {
27
+ const instrument = findInstrument(instruments, instrumentName);
28
+ const config = instrument.defaultConfig();
29
+ const state = instrument.initialState(config);
30
+ const record: TrialRecord = {
31
+ id: crypto.randomUUID(),
32
+ instrumentName,
33
+ config,
34
+ state,
35
+ view: initialView(instrument),
36
+ undoStack: { past: [], future: [] },
37
+ };
38
+ return [...trials, record];
39
+ }
40
+
41
+ /** Insert a deep copy of a trial directly after it. The copy starts with
42
+ * an empty undo history — the original's is not shared. */
43
+ export function cloneTrial(trials: TrialRecord[], id: string): TrialRecord[] {
44
+ const sourceIdx = trials.findIndex((w) => w.id === id);
45
+ const source = trials[sourceIdx];
46
+ if (!source) return trials;
47
+ const clone: TrialRecord = {
48
+ ...source,
49
+ id: crypto.randomUUID(),
50
+ config: structuredClone(source.config),
51
+ state: structuredClone(source.state),
52
+ view: structuredClone(source.view),
53
+ undoStack: { past: [], future: [] },
54
+ };
55
+ return [...trials.slice(0, sourceIdx + 1), clone, ...trials.slice(sourceIdx + 1)];
56
+ }
57
+
58
+ /** Remove a trial, unless it is the last one — a lab always has at least
59
+ * one. */
60
+ export function closeTrial(trials: TrialRecord[], id: string): TrialRecord[] {
61
+ if (trials.length <= 1) return trials;
62
+ const next = trials.filter((w) => w.id !== id);
63
+ return next.length === trials.length ? trials : next;
64
+ }
65
+
66
+ /** Return a trial to its instrument's defaults, keeping its id and its
67
+ * place in the list. */
68
+ export function resetTrial(
69
+ trials: TrialRecord[],
70
+ id: string,
71
+ instruments: InstrumentList,
72
+ ): TrialRecord[] {
73
+ const idx = trials.findIndex((w) => w.id === id);
74
+ const current = trials[idx];
75
+ if (!current) return trials;
76
+ const instrument = findInstrument(instruments, current.instrumentName);
77
+ const config = instrument.defaultConfig();
78
+ const state = instrument.initialState(config);
79
+ const reset: TrialRecord = {
80
+ ...current,
81
+ config,
82
+ state,
83
+ view: initialView(instrument),
84
+ };
85
+ return [...trials.slice(0, idx), reset, ...trials.slice(idx + 1)];
86
+ }
87
+
88
+ /**
89
+ * Reorder to match `ids`. Ids the list doesn't mention keep their relative
90
+ * order at the end, and ids it names that no longer exist are dropped — a
91
+ * reorder that raced a close should not resurrect the closed trial.
92
+ */
93
+ export function reorderTrials(trials: TrialRecord[], ids: readonly string[]): TrialRecord[] {
94
+ const byId = new Map(trials.map((w) => [w.id, w]));
95
+ const named = ids.map((id) => byId.get(id)).filter((w): w is TrialRecord => w !== undefined);
96
+ const seen = new Set(named.map((w) => w.id));
97
+ const rest = trials.filter((w) => !seen.has(w.id));
98
+ return [...named, ...rest];
99
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatZoom } from './format';
3
+
4
+ describe('formatZoom', () => {
5
+ it('shows a percentage up to and including 2x', () => {
6
+ expect(formatZoom(0.5)).toBe('50%');
7
+ expect(formatZoom(1)).toBe('100%');
8
+ expect(formatZoom(1.5)).toBe('150%');
9
+ expect(formatZoom(2)).toBe('200%');
10
+ });
11
+
12
+ it('switches to a multiplier above 2x', () => {
13
+ expect(formatZoom(2.5)).toBe('2.5x');
14
+ expect(formatZoom(4)).toBe('4x');
15
+ expect(formatZoom(16)).toBe('16x');
16
+ });
17
+
18
+ it('rounds the multiplier to one decimal and drops a trailing zero', () => {
19
+ expect(formatZoom(3.04)).toBe('3x');
20
+ expect(formatZoom(3.06)).toBe('3.1x');
21
+ });
22
+
23
+ it('drops the decimal and groups thousands past 100x', () => {
24
+ expect(formatZoom(100)).toBe('100x');
25
+ expect(formatZoom(1009.74)).toBe('1,010x');
26
+ expect(formatZoom(99.9)).toBe('99.9x');
27
+ });
28
+
29
+ it('passes non-finite zoom through rather than printing NaN%', () => {
30
+ expect(formatZoom(Number.NaN)).toBe('NaN');
31
+ expect(formatZoom(Number.POSITIVE_INFINITY)).toBe('Infinity');
32
+ });
33
+ });
package/src/ui/format.ts CHANGED
@@ -17,3 +17,14 @@ export function formatNumber(n: number, fractionDigits?: number): string {
17
17
  export function parseSignedNumber(s: string): number {
18
18
  return Number(s.replace(MINUS, '-'));
19
19
  }
20
+
21
+ /** Zoom for display. Below 2x a percentage reads naturally; past it the
22
+ * numbers get long and a multiplier is what people say out loud, so 250%
23
+ * shows as `2.5x`. Past 100x a tenth is noise, so the decimal is dropped
24
+ * and thousands are grouped: `1009.74` reads as `1,010x`. */
25
+ export function formatZoom(zoom: number): string {
26
+ if (!Number.isFinite(zoom)) return String(zoom);
27
+ if (zoom <= 2) return `${formatNumber(Math.round(zoom * 100))}%`;
28
+ if (zoom >= 100) return `${Math.round(zoom).toLocaleString('en-US')}x`;
29
+ return `${formatNumber(Math.round(zoom * 10) / 10)}x`;
30
+ }
@@ -45,7 +45,7 @@ export function CurveField({
45
45
  }: CurveFieldProps) {
46
46
  const points: ControlPoint[] = useMemo(() => {
47
47
  const out: ControlPoint[] = [];
48
- for (let i = 0; i + 1 < values.length; i += 2) out.push({ x: values[i]!, y: values[i + 1]! });
48
+ for (let i = 0; i + 1 < values.length; i += 2) out.push({ x: values[i], y: values[i + 1] });
49
49
  return out;
50
50
  }, [values]);
51
51
 
@@ -53,9 +53,9 @@ export function CurveField({
53
53
  (next: ControlPoint[]) => {
54
54
  const flat: number[] = new Array(next.length * 2);
55
55
  for (let i = 0; i < next.length; i++) {
56
- const ySnap = Math.round(next[i]!.y / step) * step;
56
+ const ySnap = Math.round(next[i].y / step) * step;
57
57
  const y = Math.max(min, Math.min(max, ySnap));
58
- flat[i * 2] = next[i]!.x;
58
+ flat[i * 2] = next[i].x;
59
59
  flat[i * 2 + 1] = y;
60
60
  }
61
61
  dlog('curve-field', 'handleChange', {
@@ -71,7 +71,7 @@ export function CurveField({
71
71
  const handleFlip = useCallback(() => {
72
72
  const flipped: Array<{ x: number; y: number }> = [];
73
73
  for (let i = 0; i + 1 < values.length; i += 2) {
74
- flipped.push({ x: 1 - values[i]!, y: values[i + 1]! });
74
+ flipped.push({ x: 1 - values[i], y: values[i + 1] });
75
75
  }
76
76
  flipped.sort((a, b) => a.x - b.x);
77
77
  const out: number[] = [];
@@ -83,8 +83,8 @@ export function CurveField({
83
83
  const mid = (min + max) / 2;
84
84
  const flipped: number[] = new Array(values.length);
85
85
  for (let i = 0; i + 1 < values.length; i += 2) {
86
- flipped[i] = values[i]!;
87
- flipped[i + 1] = 2 * mid - values[i + 1]!;
86
+ flipped[i] = values[i];
87
+ flipped[i + 1] = 2 * mid - values[i + 1];
88
88
  }
89
89
  onChange(flipped);
90
90
  }, [values, min, max, onChange]);
@@ -290,9 +290,9 @@ export const RightSidebarTails: Story = {
290
290
  render: () => {
291
291
  function TailsList() {
292
292
  const [tails, setTails] = useState<TailItem[]>([
293
- { id: 1, shape: 'classic', accent: TAIL_PALETTE[0]! },
294
- { id: 2, shape: 'bubbles', accent: TAIL_PALETTE[1]! },
295
- { id: 3, shape: 'wavy', accent: TAIL_PALETTE[2]! },
293
+ { id: 1, shape: 'classic', accent: TAIL_PALETTE[0] },
294
+ { id: 2, shape: 'bubbles', accent: TAIL_PALETTE[1] },
295
+ { id: 3, shape: 'wavy', accent: TAIL_PALETTE[2] },
296
296
  ]);
297
297
  const reorder = (sourceId: number, targetId: number, position: 'before' | 'after') => {
298
298
  setTails((prev) => {
@@ -1,151 +0,0 @@
1
- import * as react from 'react';
2
- import { ReactNode, ReactElement } from 'react';
3
- import { StoreApi } from 'zustand/vanilla';
4
-
5
- /** A workspace's undo history, as snapshots of its state either side of the
6
- * present. */
7
- interface UndoStack {
8
- past: unknown[];
9
- future: unknown[];
10
- }
11
- /** One workspace as the store holds it: which instrument it runs, that
12
- * instrument's config and state, the camera, and the undo history. */
13
- interface WorkspaceRecord<TS = unknown, TC = unknown> {
14
- id: string;
15
- instrumentName: string;
16
- config: TC;
17
- state: TS;
18
- view: {
19
- zoom: number;
20
- pan: {
21
- x: number;
22
- y: number;
23
- };
24
- };
25
- undoStack: UndoStack;
26
- }
27
- /** A named, saved copy of a workspace's config and state, restorable into any
28
- * workspace running the same instrument. */
29
- interface SavedSnapshot {
30
- id: string;
31
- name: string;
32
- workspaceId: string;
33
- instrumentName: string;
34
- config: unknown;
35
- state: unknown;
36
- savedAt: number;
37
- }
38
- /** `auto` follows the OS; the other two are an explicit choice. */
39
- type LabMode = 'auto' | 'light' | 'dark';
40
- /** Everything a lab persists: its workspaces, its saved snapshots, and the
41
- * chosen color mode. */
42
- interface LabStoreState {
43
- workspaces: WorkspaceRecord[];
44
- savedSnapshots: SavedSnapshot[];
45
- mode: LabMode;
46
- /** Per-workspace tile extents, keyed by workspace id. Opaque here — the
47
- * shape belongs to whatever lays the workspaces out. */
48
- layout: Record<string, unknown>;
49
- }
50
- /** Where a lab persists itself. Implementations are keyed string storage and
51
- * nothing more, so the same store works against localStorage, the URL hash,
52
- * or memory. */
53
- interface StorageAdapter {
54
- read(key: string): string | null;
55
- write(key: string, value: string): void;
56
- delete?(key: string): void;
57
- }
58
- /** What `useExperimentState` hands an instrument: its state and config, with
59
- * a setter for each. */
60
- interface ExperimentStateHandle<TS, TC> {
61
- state: TS;
62
- setState: (next: TS | ((prev: TS) => TS)) => void;
63
- config: TC;
64
- setConfig: (key: keyof TC, value: TC[keyof TC]) => void;
65
- }
66
- /** Options for `createLabStore`. `storageKey` namespaces the keys written, so
67
- * two labs on one origin do not collide. */
68
- interface CreateLabStoreOptions {
69
- storageKey: string;
70
- storage: StorageAdapter;
71
- initialMode?: LabMode;
72
- }
73
- /** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
74
- * instrument whose state is already JSON-safe needs no entry. */
75
- type InstrumentSerializers = Record<string, {
76
- serialize?: (state: unknown) => unknown;
77
- deserialize?: (data: unknown) => unknown;
78
- } | undefined>;
79
-
80
- /** Persist to `localStorage` — state survives a reload and a new tab. Falls
81
- * back to no-ops when storage is unavailable. */
82
- declare const localStorageAdapter: StorageAdapter;
83
- /** Persist to `sessionStorage` — state survives a reload but not a new tab. */
84
- declare const sessionStorageAdapter: StorageAdapter;
85
- /** Persist into the URL fragment, so the page's link carries its state and
86
- * can be shared or bookmarked. */
87
- declare const urlHashAdapter: StorageAdapter;
88
- /** An in-memory store, discarded on reload. For tests, and for labs that
89
- * should start fresh every time. */
90
- declare function createMemoryAdapter(): StorageAdapter;
91
- /** Persists nothing and reads back nothing. */
92
- declare const noneAdapter: StorageAdapter;
93
-
94
- /** Every mutation a lab store supports: managing workspaces, saving and
95
- * restoring snapshots, and setting the color mode. */
96
- interface LabStoreActions {
97
- addWorkspace: (record: Omit<WorkspaceRecord, 'undoStack'>) => void;
98
- removeWorkspace: (id: string) => void;
99
- updateWorkspaceState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
100
- updateWorkspaceConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
101
- updateWorkspaceView: (id: string, view: WorkspaceRecord['view']) => void;
102
- updateWorkspaceUndoStack: (id: string, next: WorkspaceRecord['undoStack'] | ((prev: WorkspaceRecord['undoStack']) => WorkspaceRecord['undoStack'])) => void;
103
- setWorkspaceInstrument: (id: string, instrumentName: string) => void;
104
- saveSnapshot: (workspaceId: string, name: string) => void;
105
- loadSnapshot: (snapshotId: string, workspaceId: string) => void;
106
- deleteSnapshot: (snapshotId: string) => void;
107
- listSnapshots: (workspaceId?: string) => SavedSnapshot[];
108
- setMode: (mode: LabMode) => void;
109
- setLayout: (layout: Record<string, unknown>) => void;
110
- }
111
- /** A lab's store: its state and actions, plus the hook instruments use to
112
- * register how their state is serialized. */
113
- type LabStore = StoreApi<LabStoreState & LabStoreActions> & {
114
- registerSerializers: (s: InstrumentSerializers) => void;
115
- };
116
- /** Build a lab store, hydrating from storage if anything was saved under the
117
- * same key. Writes back are debounced. */
118
- declare function createLabStore(options: CreateLabStoreOptions): LabStore;
119
-
120
- type LabStoreCtx = {
121
- store: LabStore;
122
- } | null;
123
- /** Context carrying the lab store. Prefer `useLabStore`; this is exported for
124
- * code that needs to read the context without subscribing. */
125
- declare const LabStoreContext: react.Context<LabStoreCtx>;
126
- /** Provides a lab store to its subtree. */
127
- declare function LabStoreProvider({ store, children, }: {
128
- store: LabStore;
129
- children: ReactNode;
130
- }): ReactElement;
131
- /** Subscribe to the whole lab store. Throws outside a `<LabStoreProvider>`. */
132
- declare function useLabStore(): LabStoreState & ReturnType<LabStore['getState']>;
133
- /** Context carrying which workspace the subtree belongs to. */
134
- declare const WorkspaceIdContext: react.Context<string | null>;
135
- /** Names the workspace its subtree belongs to, so an instrument's hooks can
136
- * find their own record in the store without being passed an id. */
137
- declare function WorkspaceIdProvider({ workspaceId, children, }: {
138
- workspaceId: string;
139
- children: ReactNode;
140
- }): ReactElement;
141
- /** The id of the workspace this component is inside. Throws outside a
142
- * `<WorkspaceIdProvider>`. */
143
- declare function useWorkspaceId(): string;
144
-
145
- /** An instrument's own state and config, plus setters. Reads the surrounding
146
- * workspace id, so an instrument never has to know which workspace it is
147
- * running in. Throws outside a lab store and workspace. */
148
- declare function useExperimentState<TS = unknown, TC = unknown>(): ExperimentStateHandle<TS, TC>;
149
-
150
- export { LabStoreContext as b, LabStoreProvider as c, WorkspaceIdContext as f, WorkspaceIdProvider as g, createLabStore as h, createMemoryAdapter as i, useExperimentState as j, useLabStore as k, localStorageAdapter as l, useWorkspaceId as m, noneAdapter as n, sessionStorageAdapter as s, urlHashAdapter as u };
151
- export type { CreateLabStoreOptions as C, ExperimentStateHandle as E, InstrumentSerializers as I, LabStore as L, StorageAdapter as S, UndoStack as U, WorkspaceRecord as W, LabStoreActions as a, LabStoreState as d, SavedSnapshot as e, LabMode as o };