@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
@@ -1,39 +1,45 @@
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;
42
+ setLayout: (layout: Record<string, unknown>) => void;
37
43
  }
38
44
 
39
45
  /** A lab's store: its state and actions, plus the hook instruments use to
@@ -48,53 +54,38 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
48
54
  let serializers: InstrumentSerializers = {};
49
55
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
50
56
 
51
- const workspacesRaw = options.storage.read(labStorageKey(options.storageKey, 'workspaces'));
52
- let hydratedWorkspaces: WorkspaceRecord[] = [];
53
- if (workspacesRaw) {
54
- hydratedWorkspaces = deserializeWorkspaces(workspacesRaw, serializers);
55
- }
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;
56
62
 
57
- const savesRaw = options.storage.read(labStorageKey(options.storageKey, 'saves'));
58
- let hydratedSnapshots: SavedSnapshot[] = [];
59
- if (savesRaw) {
60
- try {
61
- hydratedSnapshots = JSON.parse(savesRaw) as SavedSnapshot[];
62
- } catch {
63
- console.warn('[labkit] failed to parse saved snapshots, starting empty');
64
- hydratedSnapshots = [];
65
- }
66
- }
67
-
68
- const modeRaw = options.storage.read(labStorageKey(options.storageKey, 'theme'));
69
- // `interstellar` was the dark mode's name back when it was a theme.
70
- const stored = modeRaw === 'interstellar' ? 'dark' : modeRaw;
71
- let hydratedMode: LabMode;
72
- if (stored === 'light' || stored === 'dark' || stored === 'auto') {
73
- hydratedMode = stored;
74
- } else {
75
- hydratedMode = options.initialMode ?? 'auto';
76
- }
63
+ const hydratedTrials = deserializeTrials(hydrated.trials, serializers);
64
+ const hydratedSnapshots = hydrated.saves;
65
+ const hydratedLayout = hydrated.layout;
66
+ const hydratedMode = hydrated.mode;
77
67
 
78
68
  const store = createStore<LabStoreState & LabStoreActions>()((set, get) => ({
79
- workspaces: hydratedWorkspaces,
69
+ trials: hydratedTrials,
80
70
  savedSnapshots: hydratedSnapshots,
81
71
  mode: hydratedMode,
72
+ layout: hydratedLayout,
82
73
 
83
- addWorkspace: (record) => {
74
+ addTrial: (record) => {
84
75
  set((s) => ({
85
- workspaces: [...s.workspaces, { ...record, undoStack: emptyUndoStack() }],
76
+ trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
86
77
  }));
87
78
  scheduleFlush();
88
79
  },
89
80
 
90
- removeWorkspace: (id) => {
91
- set((s) => ({ workspaces: s.workspaces.filter((w) => w.id !== id) }));
81
+ removeTrial: (id) => {
82
+ set((s) => ({ trials: s.trials.filter((w) => w.id !== id) }));
92
83
  scheduleFlush();
93
84
  },
94
85
 
95
- updateWorkspaceState: (id, next) => {
86
+ updateTrialState: (id, next) => {
96
87
  set((s) => ({
97
- workspaces: s.workspaces.map((w) => {
88
+ trials: s.trials.map((w) => {
98
89
  if (w.id !== id) return w;
99
90
  const nextState =
100
91
  typeof next === 'function' ? (next as (prev: unknown) => unknown)(w.state) : next;
@@ -104,9 +95,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
104
95
  scheduleFlush();
105
96
  },
106
97
 
107
- updateWorkspaceConfig: (id, key, value) => {
98
+ updateTrialConfig: (id, key, value) => {
108
99
  set((s) => ({
109
- workspaces: s.workspaces.map((w) => {
100
+ trials: s.trials.map((w) => {
110
101
  if (w.id !== id) return w;
111
102
  return {
112
103
  ...w,
@@ -117,50 +108,48 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
117
108
  scheduleFlush();
118
109
  },
119
110
 
120
- updateWorkspaceView: (id, view) => {
111
+ updateTrialView: (id, view) => {
121
112
  set((s) => ({
122
- workspaces: s.workspaces.map((w) => (w.id === id ? { ...w, view } : w)),
113
+ trials: s.trials.map((w) => (w.id === id ? { ...w, view } : w)),
123
114
  }));
124
115
  scheduleFlush();
125
116
  },
126
117
 
127
- updateWorkspaceUndoStack: (id, next) => {
118
+ updateTrialUndoStack: (id, next) => {
128
119
  set((s) => ({
129
- workspaces: s.workspaces.map((w) => {
120
+ trials: s.trials.map((w) => {
130
121
  if (w.id !== id) return w;
131
122
  const undoStack =
132
123
  typeof next === 'function'
133
- ? (next as (prev: WorkspaceRecord['undoStack']) => WorkspaceRecord['undoStack'])(
134
- w.undoStack,
135
- )
124
+ ? (next as (prev: TrialRecord['undoStack']) => TrialRecord['undoStack'])(w.undoStack)
136
125
  : next;
137
126
  return { ...w, undoStack };
138
127
  }),
139
128
  }));
140
129
  },
141
130
 
142
- setWorkspaceInstrument: (id, instrumentName) => {
131
+ setTrialInstrument: (id, instrumentName) => {
143
132
  set((s) => ({
144
- workspaces: s.workspaces.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
133
+ trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
145
134
  }));
146
135
  scheduleFlush();
147
136
  },
148
137
 
149
- saveSnapshot: (workspaceId, name) => {
150
- const workspace = get().workspaces.find((w) => w.id === workspaceId);
151
- if (!workspace) return;
152
- 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];
153
142
  const serializedState = reg?.serialize
154
- ? reg.serialize(workspace.state)
155
- : structuredClone(workspace.state);
156
- const clonedConfig = structuredClone(workspace.config);
143
+ ? reg.serialize(trial.state)
144
+ : structuredClone(trial.state);
145
+ const clonedConfig = structuredClone(trial.config);
157
146
  const lastAt = get().savedSnapshots.reduce((m, sn) => (sn.savedAt > m ? sn.savedAt : m), 0);
158
147
  const savedAt = Math.max(Date.now(), lastAt + 1);
159
148
  const snapshot: SavedSnapshot = {
160
149
  id: crypto.randomUUID(),
161
150
  name,
162
- workspaceId,
163
- instrumentName: workspace.instrumentName,
151
+ trialId,
152
+ instrumentName: trial.instrumentName,
164
153
  config: clonedConfig,
165
154
  state: serializedState,
166
155
  savedAt,
@@ -169,22 +158,22 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
169
158
  scheduleFlush();
170
159
  },
171
160
 
172
- loadSnapshot: (snapshotId, workspaceId) => {
161
+ loadSnapshot: (snapshotId, trialId) => {
173
162
  const snapshot = get().savedSnapshots.find((sn) => sn.id === snapshotId);
174
163
  if (!snapshot) return;
175
- const workspace = get().workspaces.find((w) => w.id === workspaceId);
176
- if (!workspace) return;
177
- 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) {
178
167
  console.warn(
179
- `[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`,
180
169
  );
181
170
  return;
182
171
  }
183
172
  const reg = serializers[snapshot.instrumentName];
184
173
  const restoredState = reg?.deserialize ? reg.deserialize(snapshot.state) : snapshot.state;
185
174
  set((s) => ({
186
- workspaces: s.workspaces.map((w) =>
187
- 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,
188
177
  ),
189
178
  }));
190
179
  scheduleFlush();
@@ -197,9 +186,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
197
186
  scheduleFlush();
198
187
  },
199
188
 
200
- listSnapshots: (workspaceId) => {
189
+ listSnapshots: (trialId) => {
201
190
  const all = get().savedSnapshots;
202
- const filtered = workspaceId ? all.filter((sn) => sn.workspaceId === workspaceId) : all;
191
+ const filtered = trialId ? all.filter((sn) => sn.trialId === trialId) : all;
203
192
  return [...filtered].sort((a, b) => b.savedAt - a.savedAt);
204
193
  },
205
194
 
@@ -207,28 +196,115 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
207
196
  set({ mode });
208
197
  scheduleFlush();
209
198
  },
199
+
200
+ setLayout: (layout) => {
201
+ set({ layout });
202
+ scheduleFlush();
203
+ },
210
204
  }));
211
205
 
212
206
  function scheduleFlush(): void {
207
+ if (persistDisabled) return;
213
208
  if (flushTimer) clearTimeout(flushTimer);
214
209
  flushTimer = setTimeout(() => {
215
210
  const s = store.getState();
216
- options.storage.write(
217
- labStorageKey(options.storageKey, 'workspaces'),
218
- serializeWorkspaces(s.workspaces, serializers),
219
- );
220
- options.storage.write(
221
- labStorageKey(options.storageKey, 'saves'),
222
- JSON.stringify(s.savedSnapshots),
223
- );
224
- options.storage.write(labStorageKey(options.storageKey, 'theme'), s.mode);
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
+ }
225
223
  flushTimer = null;
226
224
  }, 300);
227
225
  }
228
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
+
229
231
  return Object.assign(store, {
230
232
  registerSerializers(s: InstrumentSerializers) {
231
233
  serializers = s;
232
234
  },
233
235
  });
234
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,12 +31,15 @@ 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-trial tile extents, keyed by trial id. Opaque here — the
41
+ * shape belongs to whatever lays the trials out. */
42
+ layout: Record<string, unknown>;
40
43
  }
41
44
 
42
45
  /** Where a lab persists itself. Implementations are keyed string storage and
@@ -48,9 +51,9 @@ export interface StorageAdapter {
48
51
  delete?(key: string): void;
49
52
  }
50
53
 
51
- /** What `useExperimentState` hands an instrument: its state and config, with
54
+ /** What `useTrialState` hands an instrument: its state and config, with
52
55
  * a setter for each. */
53
- export interface ExperimentStateHandle<TS, TC> {
56
+ export interface TrialStateHandle<TS, TC> {
54
57
  state: TS;
55
58
  setState: (next: TS | ((prev: TS) => TS)) => void;
56
59
  config: TC;
@@ -71,3 +74,20 @@ export type InstrumentSerializers = Record<
71
74
  string,
72
75
  { serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
73
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';
package/src/test-setup.ts CHANGED
@@ -12,6 +12,25 @@ if (jsdomWin && typeof globalThis.localStorage === 'undefined') {
12
12
  Object.defineProperty(globalThis, 'sessionStorage', { value: jsdomWin.sessionStorage });
13
13
  }
14
14
 
15
+ // windease's ContainerHost calls `new ResizeObserver` unguarded, and jsdom
16
+ // ships none. The stub must report a non-zero box: a windease container
17
+ // renders no children at all until something measures it, and jsdom's own
18
+ // geometry is always 0, so every tiled tile would vanish from the DOM.
19
+ if (typeof globalThis.ResizeObserver === 'undefined') {
20
+ globalThis.ResizeObserver = class {
21
+ #cb: ResizeObserverCallback;
22
+ constructor(cb: ResizeObserverCallback) {
23
+ this.#cb = cb;
24
+ }
25
+ observe(target: Element) {
26
+ const contentRect = { width: 1024, height: 768, x: 0, y: 0, top: 0, left: 0 };
27
+ this.#cb([{ target, contentRect } as ResizeObserverEntry], this);
28
+ }
29
+ unobserve() {}
30
+ disconnect() {}
31
+ } as unknown as typeof ResizeObserver;
32
+ }
33
+
15
34
  afterEach(() => {
16
35
  cleanup();
17
36
  });
@@ -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;