@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
@@ -1,38 +1,43 @@
1
1
  import { createStore, type StoreApi } from 'zustand/vanilla';
2
2
  import {
3
- deserializeWorkspaces,
4
- emptyUndoStack,
5
- labStorageKey,
6
- serializeWorkspaces,
7
- } from './helpers';
3
+ CURRENT_DOCUMENT_VERSION,
4
+ deleteLegacyKeys,
5
+ emptyDocument,
6
+ labDocumentKey,
7
+ MIGRATIONS,
8
+ normalizeDocument,
9
+ quarantineDocument,
10
+ readLegacyDocument,
11
+ runMigrations,
12
+ } from './document';
13
+ import { deserializeTrials, emptyUndoStack, serializeTrials } from './helpers';
8
14
  import type {
9
15
  CreateLabStoreOptions,
10
16
  InstrumentSerializers,
17
+ LabDocument,
11
18
  LabMode,
12
19
  LabStoreState,
13
20
  SavedSnapshot,
14
- WorkspaceRecord,
21
+ TrialRecord,
15
22
  } from './types';
16
23
 
17
- /** Every mutation a lab store supports: managing workspaces, saving and
24
+ /** Every mutation a lab store supports: managing trials, saving and
18
25
  * restoring snapshots, and setting the color mode. */
19
26
  export interface LabStoreActions {
20
- addWorkspace: (record: Omit<WorkspaceRecord, 'undoStack'>) => void;
21
- removeWorkspace: (id: string) => void;
22
- updateWorkspaceState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
23
- updateWorkspaceConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
24
- updateWorkspaceView: (id: string, view: WorkspaceRecord['view']) => void;
25
- updateWorkspaceUndoStack: (
27
+ addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
28
+ removeTrial: (id: string) => void;
29
+ updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
30
+ updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
31
+ updateTrialView: (id: string, view: unknown) => void;
32
+ updateTrialUndoStack: (
26
33
  id: string,
27
- next:
28
- | WorkspaceRecord['undoStack']
29
- | ((prev: WorkspaceRecord['undoStack']) => WorkspaceRecord['undoStack']),
34
+ next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
30
35
  ) => void;
31
- setWorkspaceInstrument: (id: string, instrumentName: string) => void;
32
- saveSnapshot: (workspaceId: string, name: string) => void;
33
- loadSnapshot: (snapshotId: string, workspaceId: string) => void;
36
+ setTrialInstrument: (id: string, instrumentName: string) => void;
37
+ saveSnapshot: (trialId: string, name: string) => void;
38
+ loadSnapshot: (snapshotId: string, trialId: string) => void;
34
39
  deleteSnapshot: (snapshotId: string) => void;
35
- listSnapshots: (workspaceId?: string) => SavedSnapshot[];
40
+ listSnapshots: (trialId?: string) => SavedSnapshot[];
36
41
  setMode: (mode: LabMode) => void;
37
42
  setLayout: (layout: Record<string, unknown>) => void;
38
43
  }
@@ -49,77 +54,54 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
49
54
  let serializers: InstrumentSerializers = {};
50
55
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
51
56
 
52
- const workspacesRaw = options.storage.read(labStorageKey(options.storageKey, 'workspaces'));
53
- let hydratedWorkspaces: WorkspaceRecord[] = [];
54
- if (workspacesRaw) {
55
- hydratedWorkspaces = deserializeWorkspaces(workspacesRaw, serializers);
56
- }
57
-
58
- const savesRaw = options.storage.read(labStorageKey(options.storageKey, 'saves'));
59
- let hydratedSnapshots: SavedSnapshot[] = [];
60
- if (savesRaw) {
61
- try {
62
- hydratedSnapshots = JSON.parse(savesRaw) as SavedSnapshot[];
63
- } catch {
64
- console.warn('[labkit] failed to parse saved snapshots, starting empty');
65
- hydratedSnapshots = [];
66
- }
67
- }
68
-
69
- const layoutRaw = options.storage.read(labStorageKey(options.storageKey, 'layout'));
70
- let hydratedLayout: Record<string, unknown> = {};
71
- if (layoutRaw) {
72
- try {
73
- hydratedLayout = JSON.parse(layoutRaw) as Record<string, unknown>;
74
- } catch {
75
- console.warn('[labkit] failed to parse saved layout, starting empty');
76
- hydratedLayout = {};
77
- }
78
- }
57
+ const hydration = hydrateDocument(options);
58
+ const hydrated = hydration.document;
59
+ const persistDisabled = hydration.persistDisabled;
60
+ // Cleared once the legacy keys are actually gone; see the flush.
61
+ let foldedFromLegacy = hydration.foldedFromLegacy;
79
62
 
80
- const modeRaw = options.storage.read(labStorageKey(options.storageKey, 'theme'));
81
- // `interstellar` was the dark mode's name back when it was a theme.
82
- const stored = modeRaw === 'interstellar' ? 'dark' : modeRaw;
83
- let hydratedMode: LabMode;
84
- if (stored === 'light' || stored === 'dark' || stored === 'auto') {
85
- hydratedMode = stored;
86
- } else {
87
- hydratedMode = options.initialMode ?? 'auto';
88
- }
63
+ const hydratedTrials = deserializeTrials(hydrated.trials, serializers);
64
+ const hydratedSnapshots = hydrated.saves;
65
+ const hydratedLayout = hydrated.layout;
66
+ const hydratedMode = hydrated.mode;
89
67
 
90
68
  const store = createStore<LabStoreState & LabStoreActions>()((set, get) => ({
91
- workspaces: hydratedWorkspaces,
69
+ trials: hydratedTrials,
92
70
  savedSnapshots: hydratedSnapshots,
93
71
  mode: hydratedMode,
94
72
  layout: hydratedLayout,
95
73
 
96
- addWorkspace: (record) => {
74
+ addTrial: (record) => {
97
75
  set((s) => ({
98
- workspaces: [...s.workspaces, { ...record, undoStack: emptyUndoStack() }],
76
+ trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
99
77
  }));
100
78
  scheduleFlush();
101
79
  },
102
80
 
103
- removeWorkspace: (id) => {
104
- set((s) => ({ workspaces: s.workspaces.filter((w) => w.id !== id) }));
81
+ removeTrial: (id) => {
82
+ set((s) => ({ trials: s.trials.filter((w) => w.id !== id) }));
105
83
  scheduleFlush();
106
84
  },
107
85
 
108
- updateWorkspaceState: (id, next) => {
86
+ updateTrialState: (id, next) => {
109
87
  set((s) => ({
110
- workspaces: s.workspaces.map((w) => {
88
+ trials: s.trials.map((w) => {
111
89
  if (w.id !== id) return w;
112
90
  const nextState =
113
91
  typeof next === 'function' ? (next as (prev: unknown) => unknown)(w.state) : next;
92
+ // An updater that returns its input means "nothing changed", and must not
93
+ // cost a new record: the trial re-renders on record identity, so
94
+ // allocating here turns the standard React bail-out into a render loop.
95
+ if (Object.is(nextState, w.state)) return w;
114
96
  return { ...w, state: nextState };
115
97
  }),
116
98
  }));
117
99
  scheduleFlush();
118
100
  },
119
101
 
120
- updateWorkspaceConfig: (id, key, value) => {
102
+ updateTrialConfig: (id, key, value) => {
121
103
  set((s) => ({
122
- workspaces: s.workspaces.map((w) => {
104
+ trials: s.trials.map((w) => {
123
105
  if (w.id !== id) return w;
124
106
  return {
125
107
  ...w,
@@ -130,50 +112,50 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
130
112
  scheduleFlush();
131
113
  },
132
114
 
133
- updateWorkspaceView: (id, view) => {
115
+ updateTrialView: (id, view) => {
134
116
  set((s) => ({
135
- workspaces: s.workspaces.map((w) => (w.id === id ? { ...w, view } : w)),
117
+ trials: s.trials.map((w) =>
118
+ w.id === id && !Object.is(view, w.view) ? { ...w, view } : w,
119
+ ),
136
120
  }));
137
121
  scheduleFlush();
138
122
  },
139
123
 
140
- updateWorkspaceUndoStack: (id, next) => {
124
+ updateTrialUndoStack: (id, next) => {
141
125
  set((s) => ({
142
- workspaces: s.workspaces.map((w) => {
126
+ trials: s.trials.map((w) => {
143
127
  if (w.id !== id) return w;
144
128
  const undoStack =
145
129
  typeof next === 'function'
146
- ? (next as (prev: WorkspaceRecord['undoStack']) => WorkspaceRecord['undoStack'])(
147
- w.undoStack,
148
- )
130
+ ? (next as (prev: TrialRecord['undoStack']) => TrialRecord['undoStack'])(w.undoStack)
149
131
  : next;
150
132
  return { ...w, undoStack };
151
133
  }),
152
134
  }));
153
135
  },
154
136
 
155
- setWorkspaceInstrument: (id, instrumentName) => {
137
+ setTrialInstrument: (id, instrumentName) => {
156
138
  set((s) => ({
157
- workspaces: s.workspaces.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
139
+ trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
158
140
  }));
159
141
  scheduleFlush();
160
142
  },
161
143
 
162
- saveSnapshot: (workspaceId, name) => {
163
- const workspace = get().workspaces.find((w) => w.id === workspaceId);
164
- if (!workspace) return;
165
- const reg = serializers[workspace.instrumentName];
144
+ saveSnapshot: (trialId, name) => {
145
+ const trial = get().trials.find((w) => w.id === trialId);
146
+ if (!trial) return;
147
+ const reg = serializers[trial.instrumentName];
166
148
  const serializedState = reg?.serialize
167
- ? reg.serialize(workspace.state)
168
- : structuredClone(workspace.state);
169
- const clonedConfig = structuredClone(workspace.config);
149
+ ? reg.serialize(trial.state)
150
+ : structuredClone(trial.state);
151
+ const clonedConfig = structuredClone(trial.config);
170
152
  const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
171
153
  const savedAt = Math.max(Date.now(), lastAt + 1);
172
154
  const snapshot: SavedSnapshot = {
173
155
  id: crypto.randomUUID(),
174
156
  name,
175
- workspaceId,
176
- instrumentName: workspace.instrumentName,
157
+ trialId,
158
+ instrumentName: trial.instrumentName,
177
159
  config: clonedConfig,
178
160
  state: serializedState,
179
161
  savedAt,
@@ -182,22 +164,22 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
182
164
  scheduleFlush();
183
165
  },
184
166
 
185
- loadSnapshot: (snapshotId, workspaceId) => {
167
+ loadSnapshot: (snapshotId, trialId) => {
186
168
  const snapshot = get().savedSnapshots.find((sn) => sn.id === snapshotId);
187
169
  if (!snapshot) return;
188
- const workspace = get().workspaces.find((w) => w.id === workspaceId);
189
- if (!workspace) return;
190
- if (snapshot.instrumentName !== workspace.instrumentName) {
170
+ const trial = get().trials.find((w) => w.id === trialId);
171
+ if (!trial) return;
172
+ if (snapshot.instrumentName !== trial.instrumentName) {
191
173
  console.warn(
192
- `[labkit] loadSnapshot: instrument mismatch (snapshot=${snapshot.instrumentName}, workspace=${workspace.instrumentName}); refusing to load`,
174
+ `[labkit] loadSnapshot: instrument mismatch (snapshot=${snapshot.instrumentName}, trial=${trial.instrumentName}); refusing to load`,
193
175
  );
194
176
  return;
195
177
  }
196
178
  const reg = serializers[snapshot.instrumentName];
197
179
  const restoredState = reg?.deserialize ? reg.deserialize(snapshot.state) : snapshot.state;
198
180
  set((s) => ({
199
- workspaces: s.workspaces.map((w) =>
200
- w.id === workspaceId ? { ...w, state: restoredState, config: snapshot.config } : w,
181
+ trials: s.trials.map((w) =>
182
+ w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
201
183
  ),
202
184
  }));
203
185
  scheduleFlush();
@@ -210,9 +192,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
210
192
  scheduleFlush();
211
193
  },
212
194
 
213
- listSnapshots: (workspaceId) => {
195
+ listSnapshots: (trialId) => {
214
196
  const all = get().savedSnapshots;
215
- const filtered = workspaceId ? all.filter((sn) => sn.workspaceId === workspaceId) : all;
197
+ const filtered = trialId ? all.filter((sn) => sn.trialId === trialId) : all;
216
198
  return [...filtered].sort((a, b) => b.savedAt - a.savedAt);
217
199
  },
218
200
 
@@ -228,26 +210,107 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
228
210
  }));
229
211
 
230
212
  function scheduleFlush(): void {
213
+ if (persistDisabled) return;
231
214
  if (flushTimer) clearTimeout(flushTimer);
232
215
  flushTimer = setTimeout(() => {
233
216
  const s = store.getState();
234
- options.storage.write(
235
- labStorageKey(options.storageKey, 'workspaces'),
236
- serializeWorkspaces(s.workspaces, serializers),
237
- );
238
- options.storage.write(
239
- labStorageKey(options.storageKey, 'saves'),
240
- JSON.stringify(s.savedSnapshots),
241
- );
242
- options.storage.write(labStorageKey(options.storageKey, 'theme'), s.mode);
243
- options.storage.write(labStorageKey(options.storageKey, 'layout'), JSON.stringify(s.layout));
217
+ const document: LabDocument = {
218
+ version: CURRENT_DOCUMENT_VERSION,
219
+ trials: serializeTrials(s.trials, serializers),
220
+ saves: s.savedSnapshots,
221
+ layout: s.layout,
222
+ mode: s.mode,
223
+ };
224
+ const serialized = JSON.stringify(document);
225
+ options.storage.write(labDocumentKey(options.storageKey), serialized);
226
+ if (foldedFromLegacy && deleteLegacyKeys(options.storage, options.storageKey, serialized)) {
227
+ foldedFromLegacy = false;
228
+ }
244
229
  flushTimer = null;
245
230
  }, 300);
246
231
  }
247
232
 
233
+ // A lab opened and closed without a single mutation still completes its
234
+ // fold; the flush is what removes the legacy keys.
235
+ if (foldedFromLegacy) scheduleFlush();
236
+
248
237
  return Object.assign(store, {
249
238
  registerSerializers(s: InstrumentSerializers) {
250
239
  serializers = s;
251
240
  },
252
241
  });
253
242
  }
243
+
244
+ interface HydrateResult {
245
+ document: LabDocument;
246
+ /** True when flushing would destroy the only copy of something: a document
247
+ * newer than this code understands, or an unusable one whose quarantine
248
+ * copy did not land. */
249
+ persistDisabled: boolean;
250
+ foldedFromLegacy: boolean;
251
+ }
252
+
253
+ /** Copy an unusable document aside, and report whether the store may go on
254
+ * persisting. A failed quarantine write means the copy in storage is the only
255
+ * one there is, so the store must not overwrite it. */
256
+ function setAside(
257
+ options: CreateLabStoreOptions,
258
+ raw: string,
259
+ why: string,
260
+ error?: unknown,
261
+ ): boolean {
262
+ const quarantined = quarantineDocument(options.storage, options.storageKey, raw);
263
+ const message = quarantined
264
+ ? `[labkit] ${why}; quarantined it and starting empty`
265
+ : `[labkit] ${why} and could not be quarantined; leaving it in place and not persisting`;
266
+ if (error === undefined) console.warn(message);
267
+ else console.warn(message, error);
268
+ return quarantined;
269
+ }
270
+
271
+ function hydrateDocument(options: CreateLabStoreOptions): HydrateResult {
272
+ const fallback = emptyDocument(options.initialMode ?? 'auto');
273
+ const raw = options.storage.read(labDocumentKey(options.storageKey));
274
+
275
+ let parsed: Record<string, unknown> | null = null;
276
+ if (raw !== null) {
277
+ try {
278
+ parsed = JSON.parse(raw) as Record<string, unknown>;
279
+ } catch {
280
+ const persistDisabled = !setAside(options, raw, 'lab document is unparseable');
281
+ return { document: fallback, persistDisabled, foldedFromLegacy: false };
282
+ }
283
+ } else {
284
+ parsed = readLegacyDocument(options.storage, options.storageKey, options.initialMode ?? 'auto');
285
+ }
286
+
287
+ if (parsed === null) {
288
+ return { document: fallback, persistDisabled: false, foldedFromLegacy: false };
289
+ }
290
+
291
+ const foldedFromLegacy = raw === null;
292
+ const outcome = runMigrations(parsed, MIGRATIONS, CURRENT_DOCUMENT_VERSION);
293
+
294
+ if (!outcome.ok) {
295
+ if (outcome.reason === 'future') {
296
+ console.warn(
297
+ '[labkit] lab document is from a newer version of labkit; starting empty and leaving it alone',
298
+ );
299
+ return { document: fallback, persistDisabled: true, foldedFromLegacy: false };
300
+ }
301
+ const stored = JSON.stringify(parsed);
302
+ const persistDisabled = !setAside(
303
+ options,
304
+ stored,
305
+ 'lab document failed to migrate',
306
+ outcome.error,
307
+ );
308
+ return { document: fallback, persistDisabled, foldedFromLegacy: false };
309
+ }
310
+
311
+ return {
312
+ document: normalizeDocument(outcome.doc, options.initialMode ?? 'auto'),
313
+ persistDisabled: false,
314
+ foldedFromLegacy,
315
+ };
316
+ }
@@ -1,27 +1,29 @@
1
- /** A workspace's undo history, as snapshots of its state either side of the
1
+ /** A trial's undo history, as snapshots of its state either side of the
2
2
  * present. */
3
3
  export interface UndoStack {
4
4
  past: unknown[];
5
5
  future: unknown[];
6
6
  }
7
7
 
8
- /** One workspace as the store holds it: which instrument it runs, that
8
+ /** One trial as the store holds it: which instrument it runs, that
9
9
  * instrument's config and state, the camera, and the undo history. */
10
- export interface WorkspaceRecord<TS = unknown, TC = unknown> {
10
+ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
11
11
  id: string;
12
12
  instrumentName: string;
13
13
  config: TC;
14
14
  state: TS;
15
- view: { zoom: number; pan: { x: number; y: number } };
15
+ /** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
16
+ * but never read into. A 3D lab puts an orbit here and keeps all three. */
17
+ view: TV;
16
18
  undoStack: UndoStack;
17
19
  }
18
20
 
19
- /** A named, saved copy of a workspace's config and state, restorable into any
20
- * workspace running the same instrument. */
21
+ /** A named, saved copy of a trial's config and state, restorable into any
22
+ * trial running the same instrument. */
21
23
  export interface SavedSnapshot {
22
24
  id: string;
23
25
  name: string;
24
- workspaceId: string;
26
+ trialId: string;
25
27
  instrumentName: string;
26
28
  config: unknown;
27
29
  state: unknown;
@@ -31,14 +33,14 @@ export interface SavedSnapshot {
31
33
  /** `auto` follows the OS; the other two are an explicit choice. */
32
34
  export type LabMode = 'auto' | 'light' | 'dark';
33
35
 
34
- /** Everything a lab persists: its workspaces, its saved snapshots, and the
36
+ /** Everything a lab persists: its trials, its saved snapshots, and the
35
37
  * chosen color mode. */
36
38
  export interface LabStoreState {
37
- workspaces: WorkspaceRecord[];
39
+ trials: TrialRecord[];
38
40
  savedSnapshots: SavedSnapshot[];
39
41
  mode: LabMode;
40
- /** Per-workspace tile extents, keyed by workspace id. Opaque here — the
41
- * shape belongs to whatever lays the workspaces out. */
42
+ /** Per-trial tile extents, keyed by trial id. Opaque here — the
43
+ * shape belongs to whatever lays the trials out. */
42
44
  layout: Record<string, unknown>;
43
45
  }
44
46
 
@@ -51,9 +53,9 @@ export interface StorageAdapter {
51
53
  delete?(key: string): void;
52
54
  }
53
55
 
54
- /** What `useExperimentState` hands an instrument: its state and config, with
56
+ /** What `useTrialState` hands an instrument: its state and config, with
55
57
  * a setter for each. */
56
- export interface ExperimentStateHandle<TS, TC> {
58
+ export interface TrialStateHandle<TS, TC> {
57
59
  state: TS;
58
60
  setState: (next: TS | ((prev: TS) => TS)) => void;
59
61
  config: TC;
@@ -74,3 +76,20 @@ export type InstrumentSerializers = Record<
74
76
  string,
75
77
  { serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
76
78
  >;
79
+
80
+ /** A trial as it is persisted: everything but the undo history, which is
81
+ * session-only. */
82
+ export type SerializedTrial = Omit<TrialRecord, 'undoStack'>;
83
+
84
+ /** Everything a lab persists, under one key, at a known version. */
85
+ export interface LabDocument {
86
+ version: number;
87
+ trials: SerializedTrial[];
88
+ saves: SavedSnapshot[];
89
+ layout: Record<string, unknown>;
90
+ mode: LabMode;
91
+ }
92
+
93
+ /** Migrates a document one version forward. Index `i` in the chain takes a
94
+ * version-`i` document to version `i + 1`. */
95
+ export type Migration = (doc: Record<string, unknown>) => Record<string, unknown>;
@@ -2,17 +2,17 @@ import { act, renderHook } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import { createMemoryAdapter } from './adapters';
5
- import { LabStoreProvider, WorkspaceIdProvider } from './context';
5
+ import { LabStoreProvider, TrialIdProvider } from './context';
6
6
  import { createLabStore } from './store';
7
- import { useExperimentState } from './useExperimentState';
7
+ import { useTrialState } from './useTrialState';
8
8
 
9
9
  type TestState = { count: number };
10
10
  type TestConfig = { step: number };
11
11
 
12
- function makeWrapper(workspaceId: string) {
12
+ function makeWrapper(trialId: string) {
13
13
  const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
14
- store.getState().addWorkspace({
15
- id: workspaceId,
14
+ store.getState().addTrial({
15
+ id: trialId,
16
16
  instrumentName: 'Counter',
17
17
  config: { step: 1 } satisfies TestConfig,
18
18
  state: { count: 0 } satisfies TestState,
@@ -23,51 +23,51 @@ function makeWrapper(workspaceId: string) {
23
23
  store,
24
24
  wrapper: ({ children }: { children: ReactNode }) => (
25
25
  <LabStoreProvider store={store}>
26
- <WorkspaceIdProvider workspaceId={workspaceId}>{children}</WorkspaceIdProvider>
26
+ <TrialIdProvider trialId={trialId}>{children}</TrialIdProvider>
27
27
  </LabStoreProvider>
28
28
  ),
29
29
  };
30
30
  }
31
31
 
32
- describe('useExperimentState', () => {
32
+ describe('useTrialState', () => {
33
33
  it('returns the initial state and config', () => {
34
34
  const { wrapper } = makeWrapper('w1');
35
- const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
35
+ const { result } = renderHook(() => useTrialState<TestState, TestConfig>(), { wrapper });
36
36
  expect(result.current.state.count).toBe(0);
37
37
  expect(result.current.config.step).toBe(1);
38
38
  });
39
39
 
40
40
  it('setState (plain value) updates the store', () => {
41
41
  const { wrapper } = makeWrapper('w1');
42
- const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
42
+ const { result } = renderHook(() => useTrialState<TestState, TestConfig>(), { wrapper });
43
43
  act(() => result.current.setState({ count: 42 }));
44
44
  expect(result.current.state.count).toBe(42);
45
45
  });
46
46
 
47
47
  it('setState (updater) updates the store', () => {
48
48
  const { wrapper } = makeWrapper('w1');
49
- const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
49
+ const { result } = renderHook(() => useTrialState<TestState, TestConfig>(), { wrapper });
50
50
  act(() => result.current.setState((prev) => ({ count: prev.count + 10 })));
51
51
  expect(result.current.state.count).toBe(10);
52
52
  });
53
53
 
54
54
  it('setConfig updates a config key', () => {
55
55
  const { wrapper } = makeWrapper('w1');
56
- const { result } = renderHook(() => useExperimentState<TestState, TestConfig>(), { wrapper });
56
+ const { result } = renderHook(() => useTrialState<TestState, TestConfig>(), { wrapper });
57
57
  act(() => result.current.setConfig('step', 5));
58
58
  expect(result.current.config.step).toBe(5);
59
59
  });
60
60
 
61
- it('sibling workspace does not re-render on unrelated workspace changes', () => {
61
+ it('sibling trial does not re-render on unrelated trial changes', () => {
62
62
  const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
63
- store.getState().addWorkspace({
63
+ store.getState().addTrial({
64
64
  id: 'w1',
65
65
  instrumentName: 'T',
66
66
  config: {},
67
67
  state: { n: 0 },
68
68
  view: { zoom: 1, pan: { x: 0, y: 0 } },
69
69
  });
70
- store.getState().addWorkspace({
70
+ store.getState().addTrial({
71
71
  id: 'w2',
72
72
  instrumentName: 'T',
73
73
  config: {},
@@ -79,24 +79,24 @@ describe('useExperimentState', () => {
79
79
 
80
80
  const wrapper = ({ children }: { children: ReactNode }) => (
81
81
  <LabStoreProvider store={store}>
82
- <WorkspaceIdProvider workspaceId="w2">{children}</WorkspaceIdProvider>
82
+ <TrialIdProvider trialId="w2">{children}</TrialIdProvider>
83
83
  </LabStoreProvider>
84
84
  );
85
85
 
86
86
  renderHook(
87
87
  () => {
88
88
  w2RenderCount++;
89
- return useExperimentState();
89
+ return useTrialState();
90
90
  },
91
91
  { wrapper },
92
92
  );
93
93
 
94
94
  const countBefore = w2RenderCount;
95
- act(() => store.getState().updateWorkspaceState('w1', { n: 99 }));
95
+ act(() => store.getState().updateTrialState('w1', { n: 99 }));
96
96
  expect(w2RenderCount).toBe(countBefore);
97
97
  });
98
98
 
99
99
  it('throws when used outside LabStoreProvider', () => {
100
- expect(() => renderHook(() => useExperimentState())).toThrow('[labkit]');
100
+ expect(() => renderHook(() => useTrialState())).toThrow('[labkit]');
101
101
  });
102
102
  });
@@ -0,0 +1,29 @@
1
+ import { useContext } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import { LabStoreContext, TrialIdContext } from './context';
4
+ import type { TrialStateHandle } from './types';
5
+
6
+ /** An instrument's own state and config, plus setters. Reads the surrounding
7
+ * trial id, so an instrument never has to know which trial it is
8
+ * running in. Throws outside a lab store and trial. */
9
+ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS, TC> {
10
+ const ctx = useContext(LabStoreContext);
11
+ if (!ctx) throw new Error('[labkit] useTrialState must be used inside <LabStoreProvider>');
12
+
13
+ const trialId = useContext(TrialIdContext);
14
+ if (!trialId) throw new Error('[labkit] useTrialState must be used inside <TrialIdProvider>');
15
+
16
+ const record = useStore(ctx.store, (s) => s.trials.find((w) => w.id === trialId));
17
+
18
+ if (!record) throw new Error(`[labkit] No trial found with id "${trialId}"`);
19
+
20
+ const updateTrialState = useStore(ctx.store, (s) => s.updateTrialState);
21
+ const updateTrialConfig = useStore(ctx.store, (s) => s.updateTrialConfig);
22
+
23
+ return {
24
+ state: record.state as TS,
25
+ config: record.config as TC,
26
+ setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
27
+ setConfig: (key, value) => updateTrialConfig(trialId, key as never, value as never),
28
+ };
29
+ }