@weasel-js/labkit 1.0.4 → 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 (85) hide show
  1. package/README.md +6 -6
  2. package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
  3. package/dist/_dts/{useExperimentState-D7EQnnwJ.d.ts → useTrialState-DYe2vUwN.d.ts} +60 -36
  4. package/dist/canvas/index.d.ts +1 -1
  5. package/dist/chunk-3TYUJR7Z.js +488 -0
  6. package/dist/chunk-3TYUJR7Z.js.map +1 -0
  7. package/dist/{chunk-BAPZPDDA.js → chunk-C6GJKPUI.js} +365 -340
  8. package/dist/chunk-C6GJKPUI.js.map +1 -0
  9. package/dist/{chunk-DWV7SFKR.js → chunk-DJLDIRFN.js} +28 -12
  10. package/dist/chunk-DJLDIRFN.js.map +1 -0
  11. package/dist/{chunk-DEWXYFEU.js → chunk-NRKWVTVT.js} +3 -3
  12. package/dist/{chunk-DEWXYFEU.js.map → chunk-NRKWVTVT.js.map} +1 -1
  13. package/dist/dragdrop/index.d.ts +1 -1
  14. package/dist/index.d.ts +149 -149
  15. package/dist/index.js +164 -159
  16. package/dist/index.js.map +1 -1
  17. package/dist/layers/index.d.ts +2 -2
  18. package/dist/passthrough/weasel-canvas.js +1 -1
  19. package/dist/passthrough/weasel-ui.d.ts +9 -2
  20. package/dist/passthrough/weasel-ui.js +2 -2
  21. package/dist/state/index.d.ts +12 -12
  22. package/dist/state/index.js +5 -5
  23. package/dist/state/index.js.map +1 -1
  24. package/dist/styles.css +21 -13
  25. package/dist/ui/layers/index.js +3 -3
  26. package/dist/undo/index.d.ts +1 -1
  27. package/package.json +1 -1
  28. package/src/canvas/AGENTS.md +6 -2
  29. package/src/dragdrop/dragDrop.test.tsx +4 -7
  30. package/src/index.test.ts +52 -0
  31. package/src/index.ts +11 -7
  32. package/src/instrument/SineWave.smoke.test.tsx +1 -1
  33. package/src/instrument/capabilityDetector.ts +1 -1
  34. package/src/instrument/types.ts +18 -6
  35. package/src/lab/Lab.less +16 -0
  36. package/src/lab/Lab.stories.tsx +5 -5
  37. package/src/lab/Lab.test.tsx +25 -25
  38. package/src/lab/Lab.tsx +50 -50
  39. package/src/lab/LabContext.ts +12 -12
  40. package/src/lab/LabShell.tsx +1 -1
  41. package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
  42. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
  43. package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
  44. package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +18 -18
  45. package/src/lab/index.ts +2 -2
  46. package/src/layers/AGENTS.md +3 -3
  47. package/src/primitives/Toolbar.stories.tsx +2 -2
  48. package/src/state/SingletonExperiment.test.tsx +8 -8
  49. package/src/state/SingletonExperiment.tsx +9 -9
  50. package/src/state/context.tsx +12 -12
  51. package/src/state/document.test.ts +369 -0
  52. package/src/state/document.ts +194 -0
  53. package/src/state/helpers.test.ts +74 -46
  54. package/src/state/helpers.ts +21 -29
  55. package/src/state/index.ts +16 -8
  56. package/src/state/store.test.ts +440 -41
  57. package/src/state/store.ts +159 -102
  58. package/src/state/types.ts +29 -12
  59. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  60. package/src/state/useTrialState.ts +29 -0
  61. package/src/styles.less +2 -2
  62. package/src/theme/base.less +1 -1
  63. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  64. package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
  65. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  66. package/src/trial/Trial.canvas.test.tsx +80 -0
  67. package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
  68. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -18
  69. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
  70. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
  71. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
  72. package/src/{workspace → trial}/index.ts +13 -13
  73. package/src/{workspace → trial}/slotTypes.ts +16 -16
  74. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
  75. package/src/trial/trialOps.ts +99 -0
  76. package/src/ui/format.test.ts +33 -0
  77. package/src/ui/format.ts +11 -0
  78. package/src/ui/properties/CurveField.tsx +6 -6
  79. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  80. package/dist/chunk-BAPZPDDA.js.map +0 -1
  81. package/dist/chunk-DWV7SFKR.js.map +0 -1
  82. package/dist/chunk-KSTEW2AF.js +0 -367
  83. package/dist/chunk-KSTEW2AF.js.map +0 -1
  84. package/src/state/useExperimentState.ts +0 -31
  85. 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: TrialRecord['view']) => 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,65 +54,38 @@ 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
- }
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;
68
62
 
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
- }
79
-
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;
@@ -117,9 +95,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
117
95
  scheduleFlush();
118
96
  },
119
97
 
120
- updateWorkspaceConfig: (id, key, value) => {
98
+ updateTrialConfig: (id, key, value) => {
121
99
  set((s) => ({
122
- workspaces: s.workspaces.map((w) => {
100
+ trials: s.trials.map((w) => {
123
101
  if (w.id !== id) return w;
124
102
  return {
125
103
  ...w,
@@ -130,50 +108,48 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
130
108
  scheduleFlush();
131
109
  },
132
110
 
133
- updateWorkspaceView: (id, view) => {
111
+ updateTrialView: (id, view) => {
134
112
  set((s) => ({
135
- workspaces: s.workspaces.map((w) => (w.id === id ? { ...w, view } : w)),
113
+ trials: s.trials.map((w) => (w.id === id ? { ...w, view } : w)),
136
114
  }));
137
115
  scheduleFlush();
138
116
  },
139
117
 
140
- updateWorkspaceUndoStack: (id, next) => {
118
+ updateTrialUndoStack: (id, next) => {
141
119
  set((s) => ({
142
- workspaces: s.workspaces.map((w) => {
120
+ trials: s.trials.map((w) => {
143
121
  if (w.id !== id) return w;
144
122
  const undoStack =
145
123
  typeof next === 'function'
146
- ? (next as (prev: WorkspaceRecord['undoStack']) => WorkspaceRecord['undoStack'])(
147
- w.undoStack,
148
- )
124
+ ? (next as (prev: TrialRecord['undoStack']) => TrialRecord['undoStack'])(w.undoStack)
149
125
  : next;
150
126
  return { ...w, undoStack };
151
127
  }),
152
128
  }));
153
129
  },
154
130
 
155
- setWorkspaceInstrument: (id, instrumentName) => {
131
+ setTrialInstrument: (id, instrumentName) => {
156
132
  set((s) => ({
157
- workspaces: s.workspaces.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
133
+ trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
158
134
  }));
159
135
  scheduleFlush();
160
136
  },
161
137
 
162
- saveSnapshot: (workspaceId, name) => {
163
- const workspace = get().workspaces.find((w) => w.id === workspaceId);
164
- if (!workspace) return;
165
- const reg = serializers[workspace.instrumentName];
138
+ saveSnapshot: (trialId, name) => {
139
+ const trial = get().trials.find((w) => w.id === trialId);
140
+ if (!trial) return;
141
+ const reg = serializers[trial.instrumentName];
166
142
  const serializedState = reg?.serialize
167
- ? reg.serialize(workspace.state)
168
- : structuredClone(workspace.state);
169
- const clonedConfig = structuredClone(workspace.config);
143
+ ? reg.serialize(trial.state)
144
+ : structuredClone(trial.state);
145
+ const clonedConfig = structuredClone(trial.config);
170
146
  const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
171
147
  const savedAt = Math.max(Date.now(), lastAt + 1);
172
148
  const snapshot: SavedSnapshot = {
173
149
  id: crypto.randomUUID(),
174
150
  name,
175
- workspaceId,
176
- instrumentName: workspace.instrumentName,
151
+ trialId,
152
+ instrumentName: trial.instrumentName,
177
153
  config: clonedConfig,
178
154
  state: serializedState,
179
155
  savedAt,
@@ -182,22 +158,22 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
182
158
  scheduleFlush();
183
159
  },
184
160
 
185
- loadSnapshot: (snapshotId, workspaceId) => {
161
+ loadSnapshot: (snapshotId, trialId) => {
186
162
  const snapshot = get().savedSnapshots.find((sn) => sn.id === snapshotId);
187
163
  if (!snapshot) return;
188
- const workspace = get().workspaces.find((w) => w.id === workspaceId);
189
- if (!workspace) return;
190
- if (snapshot.instrumentName !== workspace.instrumentName) {
164
+ const trial = get().trials.find((w) => w.id === trialId);
165
+ if (!trial) return;
166
+ if (snapshot.instrumentName !== trial.instrumentName) {
191
167
  console.warn(
192
- `[labkit] loadSnapshot: instrument mismatch (snapshot=${snapshot.instrumentName}, workspace=${workspace.instrumentName}); refusing to load`,
168
+ `[labkit] loadSnapshot: instrument mismatch (snapshot=${snapshot.instrumentName}, trial=${trial.instrumentName}); refusing to load`,
193
169
  );
194
170
  return;
195
171
  }
196
172
  const reg = serializers[snapshot.instrumentName];
197
173
  const restoredState = reg?.deserialize ? reg.deserialize(snapshot.state) : snapshot.state;
198
174
  set((s) => ({
199
- workspaces: s.workspaces.map((w) =>
200
- w.id === workspaceId ? { ...w, state: restoredState, config: snapshot.config } : w,
175
+ trials: s.trials.map((w) =>
176
+ w.id === trialId ? { ...w, state: restoredState, config: snapshot.config } : w,
201
177
  ),
202
178
  }));
203
179
  scheduleFlush();
@@ -210,9 +186,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
210
186
  scheduleFlush();
211
187
  },
212
188
 
213
- listSnapshots: (workspaceId) => {
189
+ listSnapshots: (trialId) => {
214
190
  const all = get().savedSnapshots;
215
- const filtered = workspaceId ? all.filter((sn) => sn.workspaceId === workspaceId) : all;
191
+ const filtered = trialId ? all.filter((sn) => sn.trialId === trialId) : all;
216
192
  return [...filtered].sort((a, b) => b.savedAt - a.savedAt);
217
193
  },
218
194
 
@@ -228,26 +204,107 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
228
204
  }));
229
205
 
230
206
  function scheduleFlush(): void {
207
+ if (persistDisabled) return;
231
208
  if (flushTimer) clearTimeout(flushTimer);
232
209
  flushTimer = setTimeout(() => {
233
210
  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));
211
+ const document: LabDocument = {
212
+ version: CURRENT_DOCUMENT_VERSION,
213
+ trials: serializeTrials(s.trials, serializers),
214
+ saves: s.savedSnapshots,
215
+ layout: s.layout,
216
+ mode: s.mode,
217
+ };
218
+ const serialized = JSON.stringify(document);
219
+ options.storage.write(labDocumentKey(options.storageKey), serialized);
220
+ if (foldedFromLegacy && deleteLegacyKeys(options.storage, options.storageKey, serialized)) {
221
+ foldedFromLegacy = false;
222
+ }
244
223
  flushTimer = null;
245
224
  }, 300);
246
225
  }
247
226
 
227
+ // A lab opened and closed without a single mutation still completes its
228
+ // fold; the flush is what removes the legacy keys.
229
+ if (foldedFromLegacy) scheduleFlush();
230
+
248
231
  return Object.assign(store, {
249
232
  registerSerializers(s: InstrumentSerializers) {
250
233
  serializers = s;
251
234
  },
252
235
  });
253
236
  }
237
+
238
+ interface HydrateResult {
239
+ document: LabDocument;
240
+ /** True when flushing would destroy the only copy of something: a document
241
+ * newer than this code understands, or an unusable one whose quarantine
242
+ * copy did not land. */
243
+ persistDisabled: boolean;
244
+ foldedFromLegacy: boolean;
245
+ }
246
+
247
+ /** Copy an unusable document aside, and report whether the store may go on
248
+ * persisting. A failed quarantine write means the copy in storage is the only
249
+ * one there is, so the store must not overwrite it. */
250
+ function setAside(
251
+ options: CreateLabStoreOptions,
252
+ raw: string,
253
+ why: string,
254
+ error?: unknown,
255
+ ): boolean {
256
+ const quarantined = quarantineDocument(options.storage, options.storageKey, raw);
257
+ const message = quarantined
258
+ ? `[labkit] ${why}; quarantined it and starting empty`
259
+ : `[labkit] ${why} and could not be quarantined; leaving it in place and not persisting`;
260
+ if (error === undefined) console.warn(message);
261
+ else console.warn(message, error);
262
+ return quarantined;
263
+ }
264
+
265
+ function hydrateDocument(options: CreateLabStoreOptions): HydrateResult {
266
+ const fallback = emptyDocument(options.initialMode ?? 'auto');
267
+ const raw = options.storage.read(labDocumentKey(options.storageKey));
268
+
269
+ let parsed: Record<string, unknown> | null = null;
270
+ if (raw !== null) {
271
+ try {
272
+ parsed = JSON.parse(raw) as Record<string, unknown>;
273
+ } catch {
274
+ const persistDisabled = !setAside(options, raw, 'lab document is unparseable');
275
+ return { document: fallback, persistDisabled, foldedFromLegacy: false };
276
+ }
277
+ } else {
278
+ parsed = readLegacyDocument(options.storage, options.storageKey, options.initialMode ?? 'auto');
279
+ }
280
+
281
+ if (parsed === null) {
282
+ return { document: fallback, persistDisabled: false, foldedFromLegacy: false };
283
+ }
284
+
285
+ const foldedFromLegacy = raw === null;
286
+ const outcome = runMigrations(parsed, MIGRATIONS, CURRENT_DOCUMENT_VERSION);
287
+
288
+ if (!outcome.ok) {
289
+ if (outcome.reason === 'future') {
290
+ console.warn(
291
+ '[labkit] lab document is from a newer version of labkit; starting empty and leaving it alone',
292
+ );
293
+ return { document: fallback, persistDisabled: true, foldedFromLegacy: false };
294
+ }
295
+ const stored = JSON.stringify(parsed);
296
+ const persistDisabled = !setAside(
297
+ options,
298
+ stored,
299
+ 'lab document failed to migrate',
300
+ outcome.error,
301
+ );
302
+ return { document: fallback, persistDisabled, foldedFromLegacy: false };
303
+ }
304
+
305
+ return {
306
+ document: normalizeDocument(outcome.doc, options.initialMode ?? 'auto'),
307
+ persistDisabled: false,
308
+ foldedFromLegacy,
309
+ };
310
+ }
@@ -1,13 +1,13 @@
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> {
11
11
  id: string;
12
12
  instrumentName: string;
13
13
  config: TC;
@@ -16,12 +16,12 @@ export interface WorkspaceRecord<TS = unknown, TC = unknown> {
16
16
  undoStack: UndoStack;
17
17
  }
18
18
 
19
- /** A named, saved copy of a workspace's config and state, restorable into any
20
- * workspace running the same instrument. */
19
+ /** A named, saved copy of a trial's config and state, restorable into any
20
+ * trial running the same instrument. */
21
21
  export interface SavedSnapshot {
22
22
  id: string;
23
23
  name: string;
24
- workspaceId: string;
24
+ trialId: string;
25
25
  instrumentName: string;
26
26
  config: unknown;
27
27
  state: unknown;
@@ -31,14 +31,14 @@ export interface SavedSnapshot {
31
31
  /** `auto` follows the OS; the other two are an explicit choice. */
32
32
  export type LabMode = 'auto' | 'light' | 'dark';
33
33
 
34
- /** Everything a lab persists: its workspaces, its saved snapshots, and the
34
+ /** Everything a lab persists: its trials, its saved snapshots, and the
35
35
  * chosen color mode. */
36
36
  export interface LabStoreState {
37
- workspaces: WorkspaceRecord[];
37
+ trials: TrialRecord[];
38
38
  savedSnapshots: SavedSnapshot[];
39
39
  mode: LabMode;
40
- /** Per-workspace tile extents, keyed by workspace id. Opaque here — the
41
- * shape belongs to whatever lays the workspaces out. */
40
+ /** Per-trial tile extents, keyed by trial id. Opaque here — the
41
+ * shape belongs to whatever lays the trials out. */
42
42
  layout: Record<string, unknown>;
43
43
  }
44
44
 
@@ -51,9 +51,9 @@ export interface StorageAdapter {
51
51
  delete?(key: string): void;
52
52
  }
53
53
 
54
- /** What `useExperimentState` hands an instrument: its state and config, with
54
+ /** What `useTrialState` hands an instrument: its state and config, with
55
55
  * a setter for each. */
56
- export interface ExperimentStateHandle<TS, TC> {
56
+ export interface TrialStateHandle<TS, TC> {
57
57
  state: TS;
58
58
  setState: (next: TS | ((prev: TS) => TS)) => void;
59
59
  config: TC;
@@ -74,3 +74,20 @@ export type InstrumentSerializers = Record<
74
74
  string,
75
75
  { serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
76
76
  >;
77
+
78
+ /** A trial as it is persisted: everything but the undo history, which is
79
+ * session-only. */
80
+ export type SerializedTrial = Omit<TrialRecord, 'undoStack'>;
81
+
82
+ /** Everything a lab persists, under one key, at a known version. */
83
+ export interface LabDocument {
84
+ version: number;
85
+ trials: SerializedTrial[];
86
+ saves: SavedSnapshot[];
87
+ layout: Record<string, unknown>;
88
+ mode: LabMode;
89
+ }
90
+
91
+ /** Migrates a document one version forward. Index `i` in the chain takes a
92
+ * version-`i` document to version `i + 1`. */
93
+ 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
+ }
package/src/styles.less CHANGED
@@ -1,6 +1,6 @@
1
1
  // Imports compile-time only. Component .less files are added in later steps.
2
2
  @import './theme/base.less';
3
- @import './lab/WorkspaceGrid.less';
3
+ @import './lab/Workspace.less';
4
4
  @import './lab/LabShell.less';
5
5
  @import './primitives/Toolbar.less';
6
6
  @import './primitives/Sidebar.less';
@@ -8,7 +8,7 @@
8
8
  @import './primitives/FpsMeter.less';
9
9
  @import './primitives/ScaleIndicator.less';
10
10
  @import './lab/Lab.less';
11
- @import './workspace/Workspace.less';
11
+ @import './trial/Trial.less';
12
12
  @import './controls/ControlPanel.less';
13
13
  @import './canvas/CanvasStack.less';
14
14
  @import './layers/LayerList.less';
@@ -61,7 +61,7 @@
61
61
  }
62
62
 
63
63
  // Cosmic background applied to the Lab root. The starscape sits as a fixed
64
- // pseudo-element so panning the workspace doesn't shift the field of stars.
64
+ // pseudo-element so panning the trial doesn't shift the field of stars.
65
65
  .lk-lab {
66
66
  position: relative;
67
67
  isolation: isolate;
@@ -1,13 +1,13 @@
1
1
  import { ControlPanel } from '../controls/ControlPanel';
2
2
  import { Sidebar } from '../primitives/Sidebar';
3
- import type { WorkspaceSidebarContext } from './slotTypes';
3
+ import type { TrialSidebarContext } from './slotTypes';
4
4
 
5
5
  /** Props for `<DefaultSidebar>`. */
6
6
  export interface DefaultSidebarProps {
7
- ctx: WorkspaceSidebarContext;
7
+ ctx: TrialSidebarContext;
8
8
  }
9
9
 
10
- /** The sidebar a workspace renders when no `sidebar` slot is supplied: a
10
+ /** The sidebar a trial renders when no `sidebar` slot is supplied: a
11
11
  * control panel over the instrument's config schema. */
12
12
  export function DefaultSidebar({ ctx }: DefaultSidebarProps) {
13
13
  if (ctx.configFields.length === 0) {