@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.
- package/README.md +6 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-D7EQnnwJ.d.ts → useTrialState-DYe2vUwN.d.ts} +60 -36
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-BAPZPDDA.js → chunk-C6GJKPUI.js} +365 -340
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-DJLDIRFN.js} +28 -12
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-NRKWVTVT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-NRKWVTVT.js.map} +1 -1
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +149 -149
- package/dist/index.js +164 -159
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +9 -2
- 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 +21 -13
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- 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/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +18 -18
- 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 +159 -102
- package/src/state/types.ts +29 -12
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- 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/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/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: 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
|
-
|
|
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,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
|
|
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
|
-
}
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -117,9 +95,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
117
95
|
scheduleFlush();
|
|
118
96
|
},
|
|
119
97
|
|
|
120
|
-
|
|
98
|
+
updateTrialConfig: (id, key, value) => {
|
|
121
99
|
set((s) => ({
|
|
122
|
-
|
|
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
|
-
|
|
111
|
+
updateTrialView: (id, view) => {
|
|
134
112
|
set((s) => ({
|
|
135
|
-
|
|
113
|
+
trials: s.trials.map((w) => (w.id === id ? { ...w, view } : w)),
|
|
136
114
|
}));
|
|
137
115
|
scheduleFlush();
|
|
138
116
|
},
|
|
139
117
|
|
|
140
|
-
|
|
118
|
+
updateTrialUndoStack: (id, next) => {
|
|
141
119
|
set((s) => ({
|
|
142
|
-
|
|
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:
|
|
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
|
-
|
|
131
|
+
setTrialInstrument: (id, instrumentName) => {
|
|
156
132
|
set((s) => ({
|
|
157
|
-
|
|
133
|
+
trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
|
|
158
134
|
}));
|
|
159
135
|
scheduleFlush();
|
|
160
136
|
},
|
|
161
137
|
|
|
162
|
-
saveSnapshot: (
|
|
163
|
-
const
|
|
164
|
-
if (!
|
|
165
|
-
const reg = serializers[
|
|
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(
|
|
168
|
-
: structuredClone(
|
|
169
|
-
const clonedConfig = structuredClone(
|
|
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
|
-
|
|
176
|
-
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,
|
|
161
|
+
loadSnapshot: (snapshotId, trialId) => {
|
|
186
162
|
const snapshot = get().savedSnapshots.find((sn) => sn.id === snapshotId);
|
|
187
163
|
if (!snapshot) return;
|
|
188
|
-
const
|
|
189
|
-
if (!
|
|
190
|
-
if (snapshot.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},
|
|
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
|
-
|
|
200
|
-
w.id ===
|
|
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: (
|
|
189
|
+
listSnapshots: (trialId) => {
|
|
214
190
|
const all = get().savedSnapshots;
|
|
215
|
-
const filtered =
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
);
|
|
242
|
-
options.storage.write(
|
|
243
|
-
options.storage
|
|
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
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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> {
|
|
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
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
|
34
|
+
/** Everything a lab persists: its trials, its saved snapshots, and the
|
|
35
35
|
* chosen color mode. */
|
|
36
36
|
export interface LabStoreState {
|
|
37
|
-
|
|
37
|
+
trials: TrialRecord[];
|
|
38
38
|
savedSnapshots: SavedSnapshot[];
|
|
39
39
|
mode: LabMode;
|
|
40
|
-
/** Per-
|
|
41
|
-
* shape belongs to whatever lays the
|
|
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 `
|
|
54
|
+
/** What `useTrialState` hands an instrument: its state and config, with
|
|
55
55
|
* a setter for each. */
|
|
56
|
-
export interface
|
|
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,
|
|
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
|
+
}
|
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/
|
|
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 './
|
|
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/theme/base.less
CHANGED
|
@@ -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
|
|
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 {
|
|
3
|
+
import type { TrialSidebarContext } from './slotTypes';
|
|
4
4
|
|
|
5
5
|
/** Props for `<DefaultSidebar>`. */
|
|
6
6
|
export interface DefaultSidebarProps {
|
|
7
|
-
ctx:
|
|
7
|
+
ctx: TrialSidebarContext;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/** The sidebar 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) {
|