@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.
- package/README.md +33 -6
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
- package/dist/canvas/index.d.ts +2 -1
- package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
- package/dist/chunk-73KA7WBO.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
- package/dist/chunk-BOHF3PQO.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/chunk-THBG7FQZ.js +167 -0
- package/dist/chunk-THBG7FQZ.js.map +1 -0
- package/dist/chunk-VUU5UXHE.js +491 -0
- package/dist/chunk-VUU5UXHE.js.map +1 -0
- package/dist/dragdrop/index.d.ts +2 -1
- package/dist/index.d.ts +211 -151
- package/dist/index.js +317 -169
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +3 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +29 -6
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +12 -12
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +31 -13
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +3 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/canvas/AGENTS.md +14 -2
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +70 -0
- package/src/index.ts +24 -7
- package/src/instrument/SineWave.smoke.test.tsx +2 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +30 -6
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +6 -7
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +50 -50
- package/src/lab/LabContext.ts +12 -12
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
- package/src/lab/index.ts +2 -2
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +21 -29
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -41
- package/src/state/store.ts +165 -102
- package/src/state/types.ts +32 -13
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +2 -2
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +157 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/trial/DefaultStatusBar.tsx +19 -0
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/trial/Trial.job.test.tsx +75 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +18 -17
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
- package/dist/chunk-BAPZPDDA.js.map +0 -1
- package/dist/chunk-DWV7SFKR.js.map +0 -1
- package/dist/chunk-KSTEW2AF.js +0 -367
- package/dist/chunk-KSTEW2AF.js.map +0 -1
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/DefaultStatusBar.tsx +0 -18
- package/src/workspace/workspaceOps.ts +0 -102
package/src/state/store.ts
CHANGED
|
@@ -1,38 +1,43 @@
|
|
|
1
1
|
import { createStore, type StoreApi } from 'zustand/vanilla';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
21
|
+
TrialRecord,
|
|
15
22
|
} from './types';
|
|
16
23
|
|
|
17
|
-
/** Every mutation a lab store supports: managing
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
32
|
-
saveSnapshot: (
|
|
33
|
-
loadSnapshot: (snapshotId: string,
|
|
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: (
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
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
|
-
|
|
69
|
+
trials: hydratedTrials,
|
|
92
70
|
savedSnapshots: hydratedSnapshots,
|
|
93
71
|
mode: hydratedMode,
|
|
94
72
|
layout: hydratedLayout,
|
|
95
73
|
|
|
96
|
-
|
|
74
|
+
addTrial: (record) => {
|
|
97
75
|
set((s) => ({
|
|
98
|
-
|
|
76
|
+
trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
|
|
99
77
|
}));
|
|
100
78
|
scheduleFlush();
|
|
101
79
|
},
|
|
102
80
|
|
|
103
|
-
|
|
104
|
-
set((s) => ({
|
|
81
|
+
removeTrial: (id) => {
|
|
82
|
+
set((s) => ({ trials: s.trials.filter((w) => w.id !== id) }));
|
|
105
83
|
scheduleFlush();
|
|
106
84
|
},
|
|
107
85
|
|
|
108
|
-
|
|
86
|
+
updateTrialState: (id, next) => {
|
|
109
87
|
set((s) => ({
|
|
110
|
-
|
|
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
|
-
|
|
102
|
+
updateTrialConfig: (id, key, value) => {
|
|
121
103
|
set((s) => ({
|
|
122
|
-
|
|
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
|
-
|
|
115
|
+
updateTrialView: (id, view) => {
|
|
134
116
|
set((s) => ({
|
|
135
|
-
|
|
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
|
-
|
|
124
|
+
updateTrialUndoStack: (id, next) => {
|
|
141
125
|
set((s) => ({
|
|
142
|
-
|
|
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:
|
|
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
|
-
|
|
137
|
+
setTrialInstrument: (id, instrumentName) => {
|
|
156
138
|
set((s) => ({
|
|
157
|
-
|
|
139
|
+
trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
|
|
158
140
|
}));
|
|
159
141
|
scheduleFlush();
|
|
160
142
|
},
|
|
161
143
|
|
|
162
|
-
saveSnapshot: (
|
|
163
|
-
const
|
|
164
|
-
if (!
|
|
165
|
-
const reg = serializers[
|
|
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(
|
|
168
|
-
: structuredClone(
|
|
169
|
-
const clonedConfig = structuredClone(
|
|
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
|
-
|
|
176
|
-
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,
|
|
167
|
+
loadSnapshot: (snapshotId, trialId) => {
|
|
186
168
|
const snapshot = get().savedSnapshots.find((sn) => sn.id === snapshotId);
|
|
187
169
|
if (!snapshot) return;
|
|
188
|
-
const
|
|
189
|
-
if (!
|
|
190
|
-
if (snapshot.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},
|
|
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
|
-
|
|
200
|
-
w.id ===
|
|
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: (
|
|
195
|
+
listSnapshots: (trialId) => {
|
|
214
196
|
const all = get().savedSnapshots;
|
|
215
|
-
const filtered =
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
);
|
|
242
|
-
options.storage.write(
|
|
243
|
-
options.storage
|
|
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
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
|
-
/** A
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
|
36
|
+
/** Everything a lab persists: its trials, its saved snapshots, and the
|
|
35
37
|
* chosen color mode. */
|
|
36
38
|
export interface LabStoreState {
|
|
37
|
-
|
|
39
|
+
trials: TrialRecord[];
|
|
38
40
|
savedSnapshots: SavedSnapshot[];
|
|
39
41
|
mode: LabMode;
|
|
40
|
-
/** Per-
|
|
41
|
-
* shape belongs to whatever lays the
|
|
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 `
|
|
56
|
+
/** What `useTrialState` hands an instrument: its state and config, with
|
|
55
57
|
* a setter for each. */
|
|
56
|
-
export interface
|
|
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,
|
|
5
|
+
import { LabStoreProvider, TrialIdProvider } from './context';
|
|
6
6
|
import { createLabStore } from './store';
|
|
7
|
-
import {
|
|
7
|
+
import { useTrialState } from './useTrialState';
|
|
8
8
|
|
|
9
9
|
type TestState = { count: number };
|
|
10
10
|
type TestConfig = { step: number };
|
|
11
11
|
|
|
12
|
-
function makeWrapper(
|
|
12
|
+
function makeWrapper(trialId: string) {
|
|
13
13
|
const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
|
|
14
|
-
store.getState().
|
|
15
|
-
id:
|
|
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
|
-
<
|
|
26
|
+
<TrialIdProvider trialId={trialId}>{children}</TrialIdProvider>
|
|
27
27
|
</LabStoreProvider>
|
|
28
28
|
),
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
describe('
|
|
32
|
+
describe('useTrialState', () => {
|
|
33
33
|
it('returns the initial state and config', () => {
|
|
34
34
|
const { wrapper } = makeWrapper('w1');
|
|
35
|
-
const { result } = renderHook(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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
|
|
61
|
+
it('sibling trial does not re-render on unrelated trial changes', () => {
|
|
62
62
|
const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
|
|
63
|
-
store.getState().
|
|
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().
|
|
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
|
-
<
|
|
82
|
+
<TrialIdProvider trialId="w2">{children}</TrialIdProvider>
|
|
83
83
|
</LabStoreProvider>
|
|
84
84
|
);
|
|
85
85
|
|
|
86
86
|
renderHook(
|
|
87
87
|
() => {
|
|
88
88
|
w2RenderCount++;
|
|
89
|
-
return
|
|
89
|
+
return useTrialState();
|
|
90
90
|
},
|
|
91
91
|
{ wrapper },
|
|
92
92
|
);
|
|
93
93
|
|
|
94
94
|
const countBefore = w2RenderCount;
|
|
95
|
-
act(() => store.getState().
|
|
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(() =>
|
|
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
|
+
}
|