@weasel-js/labkit 1.0.3 → 1.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 (94) hide show
  1. package/README.md +6 -6
  2. package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
  4. package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
  5. package/dist/canvas/index.d.ts +1 -1
  6. package/dist/chunk-3TYUJR7Z.js +488 -0
  7. package/dist/chunk-3TYUJR7Z.js.map +1 -0
  8. package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
  9. package/dist/chunk-C6GJKPUI.js.map +1 -0
  10. package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
  11. package/dist/chunk-DJLDIRFN.js.map +1 -0
  12. package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
  13. package/dist/chunk-NRKWVTVT.js.map +1 -0
  14. package/dist/dragdrop/index.d.ts +1 -1
  15. package/dist/index.d.ts +197 -148
  16. package/dist/index.js +275 -161
  17. package/dist/index.js.map +1 -1
  18. package/dist/layers/index.d.ts +2 -2
  19. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  20. package/dist/passthrough/weasel-canvas.js +1 -1
  21. package/dist/passthrough/weasel-ui.d.ts +10 -3
  22. package/dist/passthrough/weasel-ui.js +2 -2
  23. package/dist/state/index.d.ts +14 -14
  24. package/dist/state/index.js +5 -5
  25. package/dist/state/index.js.map +1 -1
  26. package/dist/styles.css +179 -14
  27. package/dist/ui/layers/index.js +3 -3
  28. package/dist/undo/index.d.ts +1 -1
  29. package/package.json +2 -1
  30. package/src/canvas/AGENTS.md +6 -2
  31. package/src/dragdrop/dragDrop.test.tsx +4 -7
  32. package/src/index.test.ts +52 -0
  33. package/src/index.ts +11 -7
  34. package/src/instrument/SineWave.smoke.test.tsx +1 -1
  35. package/src/instrument/capabilityDetector.ts +1 -1
  36. package/src/instrument/types.ts +18 -6
  37. package/src/lab/Lab.less +16 -0
  38. package/src/lab/Lab.stories.tsx +5 -5
  39. package/src/lab/Lab.test.tsx +25 -25
  40. package/src/lab/Lab.tsx +56 -44
  41. package/src/lab/LabContext.ts +12 -11
  42. package/src/lab/LabShell.tsx +1 -1
  43. package/src/lab/Workspace.less +40 -0
  44. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
  45. package/src/lab/Workspace.test.tsx +114 -0
  46. package/src/lab/Workspace.tsx +196 -0
  47. package/src/lab/index.ts +2 -4
  48. package/src/layers/AGENTS.md +3 -3
  49. package/src/primitives/Toolbar.stories.tsx +2 -2
  50. package/src/state/SingletonExperiment.test.tsx +8 -8
  51. package/src/state/SingletonExperiment.tsx +9 -9
  52. package/src/state/context.tsx +12 -12
  53. package/src/state/document.test.ts +369 -0
  54. package/src/state/document.ts +194 -0
  55. package/src/state/helpers.test.ts +74 -46
  56. package/src/state/helpers.ts +23 -31
  57. package/src/state/index.ts +16 -8
  58. package/src/state/store.test.ts +440 -40
  59. package/src/state/store.ts +166 -90
  60. package/src/state/types.ts +30 -10
  61. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  62. package/src/state/useTrialState.ts +29 -0
  63. package/src/styles.less +2 -2
  64. package/src/test-setup.ts +19 -0
  65. package/src/theme/base.less +1 -1
  66. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  67. package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
  68. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  69. package/src/trial/Trial.canvas.test.tsx +80 -0
  70. package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
  71. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
  72. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
  73. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
  74. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
  75. package/src/{workspace → trial}/index.ts +13 -12
  76. package/src/{workspace → trial}/slotTypes.ts +16 -16
  77. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
  78. package/src/trial/trialOps.ts +99 -0
  79. package/src/ui/format.test.ts +33 -0
  80. package/src/ui/format.ts +11 -0
  81. package/src/ui/properties/CurveField.tsx +6 -6
  82. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  83. package/dist/chunk-3WPOGKUP.js.map +0 -1
  84. package/dist/chunk-73PXCRCR.js.map +0 -1
  85. package/dist/chunk-N5KTQKQA.js.map +0 -1
  86. package/dist/chunk-T7OKNJTY.js +0 -351
  87. package/dist/chunk-T7OKNJTY.js.map +0 -1
  88. package/src/lab/WorkspaceGrid.less +0 -8
  89. package/src/lab/WorkspaceGrid.test.tsx +0 -40
  90. package/src/lab/WorkspaceGrid.tsx +0 -22
  91. package/src/lab/gridDims.test.ts +0 -35
  92. package/src/lab/gridDims.ts +0 -13
  93. package/src/state/useExperimentState.ts +0 -31
  94. package/src/workspace/workspaceOps.ts +0 -86
@@ -3,7 +3,7 @@ import { useStore } from 'zustand/react';
3
3
  import type { Instrument } from '../instrument/types';
4
4
  import { useLabContext } from '../lab/LabContext';
5
5
  import { LabStoreContext } from '../state/context';
6
- import type { WorkspaceRecord } from '../state/types';
6
+ import type { TrialRecord } from '../state/types';
7
7
  import { DefaultSidebar } from './DefaultSidebar';
8
8
  import { DefaultStatusBar } from './DefaultStatusBar';
9
9
  import { DefaultToolbar } from './DefaultToolbar';
@@ -11,9 +11,9 @@ import type {
11
11
  SidebarSlot,
12
12
  StatusBarSlot,
13
13
  ToolbarSlot,
14
- WorkspaceSidebarContext,
15
- WorkspaceStatusBarContext,
16
- WorkspaceToolbarContext,
14
+ TrialSidebarContext,
15
+ TrialStatusBarContext,
16
+ TrialToolbarContext,
17
17
  } from './slotTypes';
18
18
 
19
19
  export interface UndoBindings {
@@ -23,12 +23,12 @@ export interface UndoBindings {
23
23
  redo: () => void;
24
24
  }
25
25
 
26
- /** Props for `<WorkspaceChrome>`. */
27
- export interface WorkspaceChromeProps {
28
- workspaceId: string;
29
- record: WorkspaceRecord;
26
+ /** Props for `<TrialChrome>`. */
27
+ export interface TrialChromeProps {
28
+ trialId: string;
29
+ record: TrialRecord;
30
30
  instrument: Instrument;
31
- isLastWorkspace: boolean;
31
+ isLastTrial: boolean;
32
32
  toolbar?: ToolbarSlot;
33
33
  sidebar?: SidebarSlot;
34
34
  statusBar?: StatusBarSlot;
@@ -40,31 +40,31 @@ export interface WorkspaceChromeProps {
40
40
  /** The frame around a running instrument — toolbar, sidebar, status bar —
41
41
  * each replaceable by a slot. Assembles the slot contexts and wires the undo
42
42
  * keyboard shortcuts. */
43
- export function WorkspaceChrome({
44
- workspaceId,
43
+ export function TrialChrome({
44
+ trialId,
45
45
  record,
46
46
  instrument,
47
- isLastWorkspace,
47
+ isLastTrial,
48
48
  toolbar,
49
49
  sidebar,
50
50
  statusBar,
51
51
  undoBindings,
52
52
  sidebarExtras,
53
53
  children,
54
- }: WorkspaceChromeProps) {
54
+ }: TrialChromeProps) {
55
55
  const lab = useLabContext();
56
56
  const storeCtx = useContext(LabStoreContext);
57
- if (!storeCtx) throw new Error('[labkit] WorkspaceChrome requires <LabStoreProvider>');
58
- const updateWorkspaceView = useStore(storeCtx.store, (s) => s.updateWorkspaceView);
59
- const updateWorkspaceConfig = useStore(storeCtx.store, (s) => s.updateWorkspaceConfig);
60
- const updateWorkspaceState = useStore(storeCtx.store, (s) => s.updateWorkspaceState);
57
+ if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
58
+ const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
59
+ const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
60
+ const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
61
61
 
62
- const toolbarCtx = useMemo<WorkspaceToolbarContext>(() => {
62
+ const toolbarCtx = useMemo<TrialToolbarContext>(() => {
63
63
  const setZoom = (z: number): void => {
64
- updateWorkspaceView(workspaceId, { ...record.view, zoom: z });
64
+ updateTrialView(trialId, { ...record.view, zoom: z });
65
65
  };
66
66
  return {
67
- workspaceId,
67
+ trialId,
68
68
  instrumentName: record.instrumentName,
69
69
  hasUndo: instrument.undo != null,
70
70
  canUndo: undoBindings?.canUndo ?? false,
@@ -77,19 +77,19 @@ export function WorkspaceChrome({
77
77
  zoomOut: () => setZoom(record.view.zoom * 0.8),
78
78
  resetZoom: () => setZoom(1),
79
79
  hasCanvas: instrument.canvas != null,
80
- savedSnapshots: lab.savedSnapshots.filter((s) => s.workspaceId === workspaceId),
81
- saveSnapshot: (name) => lab.saveSnapshot(workspaceId, name),
82
- loadSnapshot: (snapshotId) => lab.loadSnapshot(workspaceId, snapshotId),
83
- clone: () => lab.cloneWorkspace(workspaceId),
84
- reset: () => lab.resetWorkspace(workspaceId),
85
- close: () => lab.closeWorkspace(workspaceId),
86
- isLastWorkspace,
80
+ savedSnapshots: lab.savedSnapshots.filter((s) => s.trialId === trialId),
81
+ saveSnapshot: (name) => lab.saveSnapshot(trialId, name),
82
+ loadSnapshot: (snapshotId) => lab.loadSnapshot(trialId, snapshotId),
83
+ clone: () => lab.cloneTrial(trialId),
84
+ reset: () => lab.resetTrial(trialId),
85
+ close: () => lab.closeTrial(trialId),
86
+ isLastTrial,
87
87
  };
88
- }, [workspaceId, record, instrument, lab, isLastWorkspace, updateWorkspaceView, undoBindings]);
88
+ }, [trialId, record, instrument, lab, isLastTrial, updateTrialView, undoBindings]);
89
89
 
90
- const sidebarCtx = useMemo<WorkspaceSidebarContext>(
90
+ const sidebarCtx = useMemo<TrialSidebarContext>(
91
91
  () => ({
92
- workspaceId,
92
+ trialId,
93
93
  instrumentName: record.instrumentName,
94
94
  configFields: instrument.configSchema?.() ?? [],
95
95
  config: record.config,
@@ -100,19 +100,19 @@ export function WorkspaceChrome({
100
100
  `[labkit] setConfig: unknown key "${key}" for instrument "${record.instrumentName}"`,
101
101
  );
102
102
  }
103
- updateWorkspaceConfig(workspaceId, key as never, value as never);
103
+ updateTrialConfig(trialId, key as never, value as never);
104
104
  if (instrument.onConfigChange) {
105
105
  const nextConfig = { ...prevConfig, [key]: value };
106
106
  const nextState = instrument.onConfigChange(nextConfig, prevConfig, record.state);
107
- updateWorkspaceState(workspaceId, nextState as never);
107
+ updateTrialState(trialId, nextState as never);
108
108
  }
109
109
  },
110
110
  }),
111
- [workspaceId, record, instrument, updateWorkspaceConfig, updateWorkspaceState],
111
+ [trialId, record, instrument, updateTrialConfig, updateTrialState],
112
112
  );
113
113
 
114
- const statusCtx: WorkspaceStatusBarContext = {
115
- workspaceId,
114
+ const statusCtx: TrialStatusBarContext = {
115
+ trialId,
116
116
  instrumentName: record.instrumentName,
117
117
  zoom: record.view.zoom,
118
118
  };
@@ -132,21 +132,21 @@ export function WorkspaceChrome({
132
132
 
133
133
  return (
134
134
  <section
135
- className="lk-workspace"
136
- aria-label={`Workspace ${record.instrumentName}`}
135
+ className="lk-trial"
136
+ aria-label={`Trial ${record.instrumentName}`}
137
137
  onKeyDown={handleKeyDown}
138
138
  >
139
- <div className="lk-workspace__toolbar">
139
+ <div className="lk-trial__toolbar">
140
140
  {toolbar ? toolbar(toolbarCtx) : <DefaultToolbar ctx={toolbarCtx} />}
141
141
  </div>
142
- <div className="lk-workspace__body">
143
- <div className="lk-workspace__sidebar">
142
+ <div className="lk-trial__body">
143
+ <div className="lk-trial__sidebar">
144
144
  {sidebar ? sidebar(sidebarCtx) : <DefaultSidebar ctx={sidebarCtx} />}
145
145
  {sidebarExtras}
146
146
  </div>
147
- <div className="lk-workspace__content">{children}</div>
147
+ <div className="lk-trial__content">{children}</div>
148
148
  </div>
149
- <div className="lk-workspace__status">
149
+ <div className="lk-trial__status">
150
150
  {statusBar ? statusBar(statusCtx) : <DefaultStatusBar ctx={statusCtx} />}
151
151
  </div>
152
152
  </section>
@@ -8,17 +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
- resetWorkspace,
24
- } 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,30 @@ 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
44
  zoom: number;
45
45
  }
46
46
 
47
- /** Replaces a workspace's toolbar. Receives everything the default one uses,
47
+ /** Replaces a trial's toolbar. Receives everything the default one uses,
48
48
  * 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;
49
+ export type ToolbarSlot = (ctx: TrialToolbarContext) => ReactNode;
50
+ /** Replaces a trial's sidebar. */
51
+ export type SidebarSlot = (ctx: TrialSidebarContext) => ReactNode;
52
+ /** Replaces a trial's status bar. */
53
+ export type StatusBarSlot = (ctx: TrialStatusBarContext) => ReactNode;
@@ -1,7 +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 { addWorkspace, cloneWorkspace, closeWorkspace, resetWorkspace } from './workspaceOps';
3
+ import type { TrialRecord } from '../state/types';
4
+ import { addTrial, cloneTrial, closeTrial, reorderTrials, resetTrial } from './trialOps';
5
5
 
6
6
  interface CounterState {
7
7
  count: number;
@@ -34,9 +34,9 @@ function head<T>(arr: T[]): T {
34
34
  return first;
35
35
  }
36
36
 
37
- describe('addWorkspace', () => {
37
+ describe('addTrial', () => {
38
38
  it('appends a new record using defaultConfig and initialState', () => {
39
- const out = addWorkspace([], instruments, 'Counter');
39
+ const out = addTrial([], instruments, 'Counter');
40
40
  expect(out).toHaveLength(1);
41
41
  expect(out[0]?.instrumentName).toBe('Counter');
42
42
  expect(out[0]?.config).toEqual({ step: 1 });
@@ -44,38 +44,38 @@ describe('addWorkspace', () => {
44
44
  });
45
45
 
46
46
  it('assigns a unique id', () => {
47
- const a = addWorkspace([], instruments, 'Counter');
48
- const b = addWorkspace(a, instruments, 'Counter');
47
+ const a = addTrial([], instruments, 'Counter');
48
+ const b = addTrial(a, instruments, 'Counter');
49
49
  expect(b).toHaveLength(2);
50
50
  expect(b[0]?.id).not.toBe(b[1]?.id);
51
51
  });
52
52
 
53
53
  it('uses canvas.initialView when present', () => {
54
- const out = addWorkspace([], instruments, 'CounterCanvas');
54
+ const out = addTrial([], instruments, 'CounterCanvas');
55
55
  expect(out[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
56
56
  });
57
57
 
58
58
  it('defaults view when canvas absent', () => {
59
- const out = addWorkspace([], instruments, 'Counter');
59
+ const out = addTrial([], instruments, 'Counter');
60
60
  expect(out[0]?.view).toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
61
61
  });
62
62
 
63
63
  it('throws when instrumentName is unknown', () => {
64
- expect(() => addWorkspace([], instruments, 'Missing')).toThrow();
64
+ expect(() => addTrial([], instruments, 'Missing')).toThrow();
65
65
  });
66
66
 
67
67
  it('does not mutate input array', () => {
68
- const arr: WorkspaceRecord[] = [];
69
- addWorkspace(arr, instruments, 'Counter');
68
+ const arr: TrialRecord[] = [];
69
+ addTrial(arr, instruments, 'Counter');
70
70
  expect(arr).toHaveLength(0);
71
71
  });
72
72
  });
73
73
 
74
- describe('cloneWorkspace', () => {
74
+ describe('cloneTrial', () => {
75
75
  it('inserts clone immediately after source with new id', () => {
76
- const a = addWorkspace([], instruments, 'Counter');
76
+ const a = addTrial([], instruments, 'Counter');
77
77
  const sourceId = head(a).id;
78
- const cloned = cloneWorkspace(a, sourceId);
78
+ const cloned = cloneTrial(a, sourceId);
79
79
  expect(cloned).toHaveLength(2);
80
80
  expect(cloned[0]?.id).toBe(sourceId);
81
81
  expect(cloned[1]?.id).not.toBe(sourceId);
@@ -83,60 +83,87 @@ describe('cloneWorkspace', () => {
83
83
  });
84
84
 
85
85
  it('deep-copies config/state/view', () => {
86
- const a = addWorkspace([], instruments, 'Counter');
87
- const cloned = cloneWorkspace(a, head(a).id);
86
+ const a = addTrial([], instruments, 'Counter');
87
+ const cloned = cloneTrial(a, head(a).id);
88
88
  expect(cloned[1]?.config).not.toBe(cloned[0]?.config);
89
89
  expect(cloned[1]?.state).not.toBe(cloned[0]?.state);
90
90
  expect(cloned[1]?.view).not.toBe(cloned[0]?.view);
91
91
  });
92
92
 
93
93
  it('does not mutate input', () => {
94
- const a = addWorkspace([], instruments, 'Counter');
94
+ const a = addTrial([], instruments, 'Counter');
95
95
  const before = a.length;
96
- cloneWorkspace(a, head(a).id);
96
+ cloneTrial(a, head(a).id);
97
97
  expect(a).toHaveLength(before);
98
98
  });
99
99
  });
100
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);
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);
106
106
  expect(closed).toHaveLength(1);
107
107
  expect(closed[0]?.id).toBe(arr[1]?.id);
108
108
  });
109
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);
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);
113
113
  expect(closed).toEqual(arr);
114
114
  });
115
115
  });
116
116
 
117
- describe('resetWorkspace', () => {
117
+ describe('resetTrial', () => {
118
118
  it('resets config and state to defaults; preserves instrumentName', () => {
119
- const arr = addWorkspace([], instruments, 'Counter');
119
+ const arr = addTrial([], instruments, 'Counter');
120
120
  const id = head(arr).id;
121
121
  head(arr).config = { step: 99 } as CounterConfig;
122
122
  head(arr).state = { count: 42 } as CounterState;
123
- const reset = resetWorkspace(arr, id, instruments);
123
+ const reset = resetTrial(arr, id, instruments);
124
124
  expect(reset[0]?.config).toEqual({ step: 1 });
125
125
  expect(reset[0]?.state).toEqual({ count: 0 });
126
126
  expect(reset[0]?.instrumentName).toBe('Counter');
127
127
  });
128
128
 
129
129
  it('resets view to canvas.initialView when present', () => {
130
- const arr = addWorkspace([], instruments, 'CounterCanvas');
130
+ const arr = addTrial([], instruments, 'CounterCanvas');
131
131
  head(arr).view = { zoom: 9, pan: { x: 9, y: 9 } };
132
- const reset = resetWorkspace(arr, head(arr).id, instruments);
132
+ const reset = resetTrial(arr, head(arr).id, instruments);
133
133
  expect(reset[0]?.view).toEqual({ zoom: 2, pan: { x: 5, y: 7 } });
134
134
  });
135
135
 
136
136
  it('does not mutate input', () => {
137
- const arr = addWorkspace([], instruments, 'Counter');
137
+ const arr = addTrial([], instruments, 'Counter');
138
138
  head(arr).config = { step: 99 } as CounterConfig;
139
- resetWorkspace(arr, head(arr).id, instruments);
139
+ resetTrial(arr, head(arr).id, instruments);
140
140
  expect((arr[0]?.config as CounterConfig).step).toBe(99);
141
141
  });
142
142
  });
143
+
144
+ describe('reorderTrials', () => {
145
+ const ws = (id: string) =>
146
+ ({
147
+ id,
148
+ instrumentName: 'T',
149
+ config: {},
150
+ state: {},
151
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
152
+ undoStack: { past: [], future: [] },
153
+ }) as never;
154
+
155
+ it('reorders to match the given ids', () => {
156
+ const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
157
+ expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
158
+ });
159
+
160
+ it('drops ids that no longer exist rather than resurrecting them', () => {
161
+ const next = reorderTrials([ws('a'), ws('b')], ['gone', 'b', 'a']);
162
+ expect(next.map((w) => w.id)).toEqual(['b', 'a']);
163
+ });
164
+
165
+ it('keeps unmentioned trials, after the named ones', () => {
166
+ const next = reorderTrials([ws('a'), ws('b'), ws('c')], ['c']);
167
+ expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
168
+ });
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) => {