@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.
- package/README.md +6 -6
- package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
- 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-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
- package/dist/chunk-NRKWVTVT.js.map +1 -0
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +197 -148
- package/dist/index.js +275 -161
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -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 +10 -3
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +14 -14
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +179 -14
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +2 -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 +56 -44
- package/src/lab/LabContext.ts +12 -11
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/Workspace.less +40 -0
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
- package/src/lab/Workspace.test.tsx +114 -0
- package/src/lab/Workspace.tsx +196 -0
- package/src/lab/index.ts +2 -4
- 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 +23 -31
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -40
- package/src/state/store.ts +166 -90
- package/src/state/types.ts +30 -10
- 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/test-setup.ts +19 -0
- 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 -17
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
- 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 -12
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
- 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-3WPOGKUP.js.map +0 -1
- package/dist/chunk-73PXCRCR.js.map +0 -1
- package/dist/chunk-N5KTQKQA.js.map +0 -1
- package/dist/chunk-T7OKNJTY.js +0 -351
- package/dist/chunk-T7OKNJTY.js.map +0 -1
- package/src/lab/WorkspaceGrid.less +0 -8
- package/src/lab/WorkspaceGrid.test.tsx +0 -40
- package/src/lab/WorkspaceGrid.tsx +0 -22
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -13
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/workspaceOps.ts +0 -86
package/src/state/store.ts
CHANGED
|
@@ -1,39 +1,45 @@
|
|
|
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;
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
69
|
+
trials: hydratedTrials,
|
|
80
70
|
savedSnapshots: hydratedSnapshots,
|
|
81
71
|
mode: hydratedMode,
|
|
72
|
+
layout: hydratedLayout,
|
|
82
73
|
|
|
83
|
-
|
|
74
|
+
addTrial: (record) => {
|
|
84
75
|
set((s) => ({
|
|
85
|
-
|
|
76
|
+
trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
|
|
86
77
|
}));
|
|
87
78
|
scheduleFlush();
|
|
88
79
|
},
|
|
89
80
|
|
|
90
|
-
|
|
91
|
-
set((s) => ({
|
|
81
|
+
removeTrial: (id) => {
|
|
82
|
+
set((s) => ({ trials: s.trials.filter((w) => w.id !== id) }));
|
|
92
83
|
scheduleFlush();
|
|
93
84
|
},
|
|
94
85
|
|
|
95
|
-
|
|
86
|
+
updateTrialState: (id, next) => {
|
|
96
87
|
set((s) => ({
|
|
97
|
-
|
|
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
|
-
|
|
98
|
+
updateTrialConfig: (id, key, value) => {
|
|
108
99
|
set((s) => ({
|
|
109
|
-
|
|
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
|
-
|
|
111
|
+
updateTrialView: (id, view) => {
|
|
121
112
|
set((s) => ({
|
|
122
|
-
|
|
113
|
+
trials: s.trials.map((w) => (w.id === id ? { ...w, view } : w)),
|
|
123
114
|
}));
|
|
124
115
|
scheduleFlush();
|
|
125
116
|
},
|
|
126
117
|
|
|
127
|
-
|
|
118
|
+
updateTrialUndoStack: (id, next) => {
|
|
128
119
|
set((s) => ({
|
|
129
|
-
|
|
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:
|
|
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
|
-
|
|
131
|
+
setTrialInstrument: (id, instrumentName) => {
|
|
143
132
|
set((s) => ({
|
|
144
|
-
|
|
133
|
+
trials: s.trials.map((w) => (w.id === id ? { ...w, instrumentName } : w)),
|
|
145
134
|
}));
|
|
146
135
|
scheduleFlush();
|
|
147
136
|
},
|
|
148
137
|
|
|
149
|
-
saveSnapshot: (
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
152
|
-
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];
|
|
153
142
|
const serializedState = reg?.serialize
|
|
154
|
-
? reg.serialize(
|
|
155
|
-
: structuredClone(
|
|
156
|
-
const clonedConfig = structuredClone(
|
|
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
|
-
|
|
163
|
-
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,
|
|
161
|
+
loadSnapshot: (snapshotId, trialId) => {
|
|
173
162
|
const snapshot = get().savedSnapshots.find((sn) => sn.id === snapshotId);
|
|
174
163
|
if (!snapshot) return;
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
if (snapshot.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},
|
|
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
|
-
|
|
187
|
-
w.id ===
|
|
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: (
|
|
189
|
+
listSnapshots: (trialId) => {
|
|
201
190
|
const all = get().savedSnapshots;
|
|
202
|
-
const filtered =
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
224
|
-
options.storage.write(
|
|
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
|
+
}
|
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,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
|
|
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-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 `
|
|
54
|
+
/** What `useTrialState` hands an instrument: its state and config, with
|
|
52
55
|
* a setter for each. */
|
|
53
|
-
export interface
|
|
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,
|
|
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/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
|
});
|
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;
|