@weasel-js/labkit 1.8.0 → 1.8.1
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 +24 -0
- package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
- package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
- package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
- package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
- package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
- package/dist/canvas/index.d.ts +4 -4
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +2 -2
- package/dist/chrome/index.js +7 -7
- package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
- package/dist/chunk-4RCVWX4M.js.map +1 -0
- package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
- package/dist/chunk-6IIKLXBZ.js.map +1 -0
- package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
- package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
- package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
- package/dist/chunk-7ALJOVGP.js.map +1 -0
- package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
- package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
- package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
- package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
- package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
- package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
- package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
- package/dist/chunk-KHB3KRRQ.js.map +1 -0
- package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
- package/dist/chunk-NHIO2O3O.js.map +1 -0
- package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
- package/dist/chunk-QMAY74TP.js.map +1 -0
- package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
- package/dist/chunk-RN7T7OBO.js.map +1 -0
- package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
- package/dist/chunk-RXO5UEGT.js.map +1 -0
- package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
- package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
- package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
- package/dist/chunk-T5OEUNCB.js.map +1 -0
- package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
- package/dist/chunk-UX66USTN.js.map +1 -0
- package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
- package/dist/chunk-V4SMGEYY.js.map +1 -0
- package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
- package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
- package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
- package/dist/chunk-XWR2P6MR.js.map +1 -0
- package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
- package/dist/chunk-YZA73U4A.js.map +1 -0
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +157 -15
- package/dist/index.js +893 -258
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +1 -1
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +175 -27
- package/dist/loupe/index.js +547 -141
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.js +5 -5
- package/dist/passthrough/weasel-ui.js +3 -3
- package/dist/primitives/index.d.ts +4 -19
- package/dist/primitives/index.js +7 -7
- package/dist/state/index.d.ts +5 -7
- package/dist/state/index.js +5 -5
- package/dist/styles.css +173 -38
- package/dist/surface/index.d.ts +20 -1
- package/dist/surface/index.js +2 -2
- package/dist/undo/index.d.ts +18 -7
- package/dist/undo/index.js +1 -1
- package/package.json +11 -11
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
- package/src/annotations/AnnotationOverlay.tsx +10 -1
- package/src/annotations/Annotations.less +2 -0
- package/src/annotations/Annotations.overlay.test.tsx +29 -0
- package/src/canvas/CanvasStack.test.tsx +35 -0
- package/src/canvas/CanvasStack.tsx +12 -1
- package/src/canvas/useLayerScheduler.ts +14 -0
- package/src/chrome/builtins.test.ts +14 -0
- package/src/chrome/builtins.tsx +17 -2
- package/src/chrome/regions/StatusRegion.tsx +20 -14
- package/src/chrome/regions/regions.test.tsx +28 -0
- package/src/chrome/types.ts +8 -0
- package/src/clock/clock.browser.test.less +5 -0
- package/src/clock/clock.browser.test.tsx +77 -0
- package/src/clock/clock.integration.test.tsx +95 -0
- package/src/clock/clockRegistry.test.ts +33 -0
- package/src/clock/clockRegistry.ts +46 -0
- package/src/clock/hooks.ts +44 -0
- package/src/clock/index.ts +2 -0
- package/src/clock/trialClock.test.ts +265 -0
- package/src/clock/trialClock.ts +245 -0
- package/src/clock/useClockLoop.ts +45 -0
- package/src/config/resolve.test.ts +12 -0
- package/src/config/resolve.ts +5 -0
- package/src/controls/ControlMatrix.tsx +56 -54
- package/src/index.ts +3 -2
- package/src/instrument/types.ts +21 -1
- package/src/lab/Lab.chrome.test.tsx +8 -1
- package/src/lab/Lab.presentation.test.tsx +113 -0
- package/src/lab/Lab.surface.test.tsx +20 -9
- package/src/lab/Lab.tsx +132 -257
- package/src/lab/LabLightbox.browser.test.less +17 -0
- package/src/lab/LabLightbox.browser.test.tsx +216 -0
- package/src/lab/LabShell.less +10 -3
- package/src/lab/LabSurface.tsx +129 -0
- package/src/lab/Workspace.stories.tsx +18 -0
- package/src/lab/Workspace.surface.test.tsx +4 -0
- package/src/lab/Workspace.test.tsx +37 -1
- package/src/lab/Workspace.tsx +34 -6
- package/src/lab/index.ts +2 -0
- package/src/lab/nebula.ts +30 -0
- package/src/lab/openLab.test.ts +81 -0
- package/src/lab/openLab.ts +119 -0
- package/src/lab/presentation.browser.test.less +5 -0
- package/src/lab/presentation.browser.test.tsx +119 -0
- package/src/lab/presentation.less +64 -0
- package/src/lab/presentation.tsx +47 -0
- package/src/lab/useFocusPick.ts +43 -0
- package/src/lightbox/Lightbox.browser.test.less +14 -0
- package/src/lightbox/Lightbox.browser.test.tsx +113 -0
- package/src/lightbox/Lightbox.less +128 -0
- package/src/lightbox/Lightbox.test.tsx +195 -0
- package/src/lightbox/Lightbox.tsx +205 -0
- package/src/lightbox/LightboxContext.ts +38 -0
- package/src/lightbox/LightboxLayers.tsx +40 -0
- package/src/lightbox/index.ts +8 -0
- package/src/lightbox/modal.ts +94 -0
- package/src/loupe/AGENTS.md +40 -2
- package/src/loupe/CanvasLoupe.tsx +5 -4
- package/src/loupe/DomLoupe.tsx +7 -5
- package/src/loupe/Loupe.less +15 -0
- package/src/loupe/LoupeBubble.tsx +35 -13
- package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
- package/src/loupe/SourceLoupe.tsx +60 -0
- package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
- package/src/loupe/TrialLoupe.tsx +126 -28
- package/src/loupe/canvasLens.ts +20 -17
- package/src/loupe/index.ts +26 -2
- package/src/loupe/sourceLens.test.ts +65 -0
- package/src/loupe/sourceLens.ts +97 -0
- package/src/loupe/types.test.ts +6 -0
- package/src/loupe/types.ts +109 -7
- package/src/loupe/useLoupe.test.tsx +188 -3
- package/src/loupe/useLoupe.ts +106 -11
- package/src/primitives/index.ts +2 -2
- package/src/specimen/Specimen.tsx +0 -7
- package/src/state/helpers.test.ts +4 -11
- package/src/state/helpers.ts +2 -8
- package/src/state/index.ts +0 -2
- package/src/state/openLabStore.test.ts +10 -8
- package/src/state/openLabStore.ts +1 -1
- package/src/state/store.test.ts +2 -2
- package/src/state/store.ts +15 -16
- package/src/state/types.ts +9 -9
- package/src/styles.less +2 -1
- package/src/surface/AGENTS.md +13 -0
- package/src/surface/index.ts +1 -0
- package/src/surface/useSurfaceTile.test.tsx +4 -0
- package/src/surface/useSurfaceTile.ts +17 -1
- package/src/surface/useTiledSurface.test.tsx +65 -0
- package/src/surface/useTiledSurface.ts +68 -1
- package/src/trial/Trial.stories.tsx +0 -1
- package/src/trial/Trial.test.tsx +40 -3
- package/src/trial/Trial.tsx +87 -43
- package/src/trial/TrialChrome.tsx +20 -2
- package/src/trial/trialOps.test.ts +1 -2
- package/src/trial/trialOps.ts +2 -3
- package/src/undo/stateOp.ts +21 -0
- package/src/undo/undoStack.ts +18 -6
- package/dist/chunk-3L4MLLQB.js.map +0 -1
- package/dist/chunk-7KAETQSS.js.map +0 -1
- package/dist/chunk-EESJUSYR.js.map +0 -1
- package/dist/chunk-J5HJJDLF.js.map +0 -1
- package/dist/chunk-NVKBL7ZE.js.map +0 -1
- package/dist/chunk-PMLZP7RT.js.map +0 -1
- package/dist/chunk-RL2LOLNI.js.map +0 -1
- package/dist/chunk-T6DLGEJB.js.map +0 -1
- package/dist/chunk-WIG6XHJ7.js.map +0 -1
- package/dist/chunk-WURZTJGQ.js.map +0 -1
- package/dist/chunk-XXACAPVU.js.map +0 -1
- package/dist/chunk-YDE6XATS.js.map +0 -1
- package/dist/chunk-YX75W6EK.js.map +0 -1
- package/src/primitives/StatusBar.less +0 -34
- package/src/primitives/StatusBar.stories.tsx +0 -24
- package/src/primitives/StatusBar.test.tsx +0 -25
- package/src/primitives/StatusBar.tsx +0 -28
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an instrument declares to get a clock: how long a pass runs, how many
|
|
3
|
+
* passes, and how it opens. Shaped like a blits voice spec.
|
|
4
|
+
*/
|
|
5
|
+
export interface ClockCapability {
|
|
6
|
+
/** ms per pass. Omitted, the run never ends. */
|
|
7
|
+
duration?: number;
|
|
8
|
+
/** Passes: `true` endless, `false` one, `n` that many. Default `false`. */
|
|
9
|
+
loop?: boolean | number;
|
|
10
|
+
/** The rate it opens at; `0`, the default, opens paused. */
|
|
11
|
+
rate?: number;
|
|
12
|
+
/** `false` for an instrument whose state is built up by running, so time can
|
|
13
|
+
* only move by running: no seek and no negative rate. Default `true`. */
|
|
14
|
+
seekable?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A trial's time. A position (`elapsed`) moved by a signed `rate`, synced by
|
|
19
|
+
* the lab's frame loop once a frame. Named after blits' owner handle and
|
|
20
|
+
* `Ticked`, so a blits ticker can drive one and blits voices can play under it.
|
|
21
|
+
*/
|
|
22
|
+
export interface TrialClock {
|
|
23
|
+
/** ms the trial has played, rate applied, counted across passes. */
|
|
24
|
+
readonly elapsed: number;
|
|
25
|
+
/** Which pass `elapsed` falls in, 0 first; the last once the run is over. */
|
|
26
|
+
readonly pass: number;
|
|
27
|
+
/** How far through its pass, 0 to 1; 1 once the run is over. 0 with no duration. */
|
|
28
|
+
readonly phase: number;
|
|
29
|
+
/** Signed: 0 pauses and a negative rate plays backward. A write changes speed
|
|
30
|
+
* at once and replaces any ramp. */
|
|
31
|
+
rate: number;
|
|
32
|
+
/** Passes, as `ClockCapability.loop`. */
|
|
33
|
+
loop: boolean | number;
|
|
34
|
+
/** Move `rate` to `to` linearly over `over` real ms; position stays continuous. */
|
|
35
|
+
ramp(to: number, over: number): void;
|
|
36
|
+
/** Move to `elapsed` ms from the start, clamped to the run. Never runs state
|
|
37
|
+
* forward to meet it. Throws on a clock that is not seekable. */
|
|
38
|
+
seek(elapsed: number): void;
|
|
39
|
+
/** The frame loop reports the time, once a frame. */
|
|
40
|
+
sync(timestamp: number): void;
|
|
41
|
+
/** The time between now and the next sync does not count. */
|
|
42
|
+
rebase(): void;
|
|
43
|
+
/** Another frame would change nothing: paused, no ramp, nothing changed since
|
|
44
|
+
* the last sync. */
|
|
45
|
+
readonly inert: boolean;
|
|
46
|
+
/** Called when a change needs frames again, once until the next sync. */
|
|
47
|
+
onWake(fn: () => void): () => void;
|
|
48
|
+
/** Called on a rate, seek or loop change, and when the run ends; never per frame. */
|
|
49
|
+
subscribe(fn: () => void): () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A clock, and what labkit alone does with it. */
|
|
53
|
+
export interface TrialClockHandle {
|
|
54
|
+
clock: TrialClock;
|
|
55
|
+
/** Back to 0 at the declared rate — the trial's Reset, allowed on a clock
|
|
56
|
+
* that is not seekable. */
|
|
57
|
+
reset(): void;
|
|
58
|
+
/** Called after each sync that moved the clock or followed a change. */
|
|
59
|
+
onFrame(fn: (elapsed: number, pass: number) => void): () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const passesOf = (loop: boolean | number): number =>
|
|
63
|
+
loop === true ? Number.POSITIVE_INFINITY : loop === false ? 1 : Math.max(1, loop);
|
|
64
|
+
|
|
65
|
+
const checkRate = (rate: number, seekable: boolean): void => {
|
|
66
|
+
if (!Number.isFinite(rate)) throw new RangeError(`labkit: a clock's rate is finite, not ${rate}`);
|
|
67
|
+
if (rate < 0 && !seekable)
|
|
68
|
+
throw new RangeError('labkit: a clock that is not seekable cannot run backward');
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A trial's clock, opened from its declaration and, for a trial reopened,
|
|
72
|
+
* where it stood. */
|
|
73
|
+
export function createTrialClock(
|
|
74
|
+
spec: ClockCapability,
|
|
75
|
+
start?: { elapsed: number; rate: number },
|
|
76
|
+
): TrialClockHandle {
|
|
77
|
+
const duration = spec.duration ?? Number.POSITIVE_INFINITY;
|
|
78
|
+
const seekable = spec.seekable ?? true;
|
|
79
|
+
const openRate = spec.rate ?? 0;
|
|
80
|
+
checkRate(openRate, seekable);
|
|
81
|
+
|
|
82
|
+
let loop = spec.loop ?? false;
|
|
83
|
+
let span = duration * passesOf(loop);
|
|
84
|
+
let rate = start ? start.rate : openRate;
|
|
85
|
+
let elapsed = start ? Math.min(Math.max(0, start.elapsed), span) : 0;
|
|
86
|
+
let ramp: { to: number; left: number } | null = null;
|
|
87
|
+
let last = Number.NaN;
|
|
88
|
+
/** Changed since the last sync, so the next one is due even when paused. */
|
|
89
|
+
let changed = false;
|
|
90
|
+
/** Inert as of the last sync: the next one only records the time. */
|
|
91
|
+
let slept = false;
|
|
92
|
+
let wakeFired = false;
|
|
93
|
+
const wakes = new Set<() => void>();
|
|
94
|
+
const subs = new Set<() => void>();
|
|
95
|
+
const frames = new Set<(elapsed: number, pass: number) => void>();
|
|
96
|
+
|
|
97
|
+
const isInert = (): boolean => rate === 0 && ramp === null && !changed;
|
|
98
|
+
const notify = (): void => {
|
|
99
|
+
for (const fn of [...subs]) fn();
|
|
100
|
+
};
|
|
101
|
+
const change = (): void => {
|
|
102
|
+
changed = true;
|
|
103
|
+
if (wakeFired) return;
|
|
104
|
+
wakeFired = true;
|
|
105
|
+
for (const fn of [...wakes]) fn();
|
|
106
|
+
};
|
|
107
|
+
const passAt = (): number =>
|
|
108
|
+
duration > 0 && Number.isFinite(duration)
|
|
109
|
+
? elapsed >= span
|
|
110
|
+
? passesOf(loop) - 1
|
|
111
|
+
: Math.floor(elapsed / duration)
|
|
112
|
+
: 0;
|
|
113
|
+
const phaseAt = (): number =>
|
|
114
|
+
duration > 0 && Number.isFinite(duration)
|
|
115
|
+
? elapsed >= span
|
|
116
|
+
? 1
|
|
117
|
+
: (elapsed % duration) / duration
|
|
118
|
+
: 0;
|
|
119
|
+
|
|
120
|
+
/** ms of trial time over `dt` real ms, ramp included; settles the ramp. */
|
|
121
|
+
const advance = (dt: number): number => {
|
|
122
|
+
if (!ramp) return rate * dt;
|
|
123
|
+
if (dt >= ramp.left) {
|
|
124
|
+
const area = ((rate + ramp.to) / 2) * ramp.left + ramp.to * (dt - ramp.left);
|
|
125
|
+
rate = ramp.to;
|
|
126
|
+
ramp = null;
|
|
127
|
+
notify();
|
|
128
|
+
return area;
|
|
129
|
+
}
|
|
130
|
+
const next = rate + ((ramp.to - rate) * dt) / ramp.left;
|
|
131
|
+
const area = ((rate + next) / 2) * dt;
|
|
132
|
+
rate = next;
|
|
133
|
+
ramp.left -= dt;
|
|
134
|
+
return area;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const clock: TrialClock = {
|
|
138
|
+
get elapsed() {
|
|
139
|
+
return elapsed;
|
|
140
|
+
},
|
|
141
|
+
get pass() {
|
|
142
|
+
return passAt();
|
|
143
|
+
},
|
|
144
|
+
get phase() {
|
|
145
|
+
return phaseAt();
|
|
146
|
+
},
|
|
147
|
+
get rate() {
|
|
148
|
+
return rate;
|
|
149
|
+
},
|
|
150
|
+
set rate(next: number) {
|
|
151
|
+
checkRate(next, seekable);
|
|
152
|
+
rate = next;
|
|
153
|
+
ramp = null;
|
|
154
|
+
change();
|
|
155
|
+
notify();
|
|
156
|
+
},
|
|
157
|
+
get loop() {
|
|
158
|
+
return loop;
|
|
159
|
+
},
|
|
160
|
+
set loop(next: boolean | number) {
|
|
161
|
+
loop = next;
|
|
162
|
+
span = duration * passesOf(loop);
|
|
163
|
+
elapsed = Math.min(elapsed, span);
|
|
164
|
+
change();
|
|
165
|
+
notify();
|
|
166
|
+
},
|
|
167
|
+
ramp(to, over) {
|
|
168
|
+
checkRate(to, seekable);
|
|
169
|
+
if (!(over > 0)) {
|
|
170
|
+
clock.rate = to;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
ramp = { to, left: over };
|
|
174
|
+
change();
|
|
175
|
+
notify();
|
|
176
|
+
},
|
|
177
|
+
seek(target) {
|
|
178
|
+
if (!seekable) throw new Error('labkit: this clock is not seekable');
|
|
179
|
+
elapsed = Math.min(Math.max(0, target), span);
|
|
180
|
+
change();
|
|
181
|
+
notify();
|
|
182
|
+
},
|
|
183
|
+
sync(timestamp) {
|
|
184
|
+
const wasChanged = changed;
|
|
185
|
+
const before = elapsed;
|
|
186
|
+
if (!Number.isNaN(last) && !slept) {
|
|
187
|
+
elapsed += advance(Math.max(0, timestamp - last));
|
|
188
|
+
if (elapsed >= span && rate > 0) {
|
|
189
|
+
elapsed = span;
|
|
190
|
+
rate = 0;
|
|
191
|
+
ramp = null;
|
|
192
|
+
notify();
|
|
193
|
+
} else if (elapsed <= 0 && rate < 0) {
|
|
194
|
+
elapsed = 0;
|
|
195
|
+
rate = 0;
|
|
196
|
+
ramp = null;
|
|
197
|
+
notify();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
last = timestamp;
|
|
201
|
+
changed = false;
|
|
202
|
+
wakeFired = false;
|
|
203
|
+
slept = isInert();
|
|
204
|
+
if (elapsed !== before || wasChanged) {
|
|
205
|
+
const pass = passAt();
|
|
206
|
+
for (const fn of [...frames]) fn(elapsed, pass);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
rebase() {
|
|
210
|
+
last = Number.NaN;
|
|
211
|
+
},
|
|
212
|
+
get inert() {
|
|
213
|
+
return isInert();
|
|
214
|
+
},
|
|
215
|
+
onWake(fn) {
|
|
216
|
+
wakes.add(fn);
|
|
217
|
+
return () => {
|
|
218
|
+
wakes.delete(fn);
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
subscribe(fn) {
|
|
222
|
+
subs.add(fn);
|
|
223
|
+
return () => {
|
|
224
|
+
subs.delete(fn);
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
clock,
|
|
231
|
+
reset() {
|
|
232
|
+
elapsed = 0;
|
|
233
|
+
rate = openRate;
|
|
234
|
+
ramp = null;
|
|
235
|
+
change();
|
|
236
|
+
notify();
|
|
237
|
+
},
|
|
238
|
+
onFrame(fn) {
|
|
239
|
+
frames.add(fn);
|
|
240
|
+
return () => {
|
|
241
|
+
frames.delete(fn);
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useVisibleRaf } from '@weasel-js/core';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import type { ClockRegistry } from './clockRegistry';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The lab's one frame loop for every trial clock: syncs each clock that is not
|
|
7
|
+
* inert, and sleeps once every clock is, until one wakes. Shaped like a blits
|
|
8
|
+
* `ticker`, so one could take its place.
|
|
9
|
+
*/
|
|
10
|
+
export function useClockLoop(registry: ClockRegistry): void {
|
|
11
|
+
const loop = useVisibleRaf(
|
|
12
|
+
(timestamp) => {
|
|
13
|
+
let live = false;
|
|
14
|
+
for (const { clock } of registry.all()) {
|
|
15
|
+
if (clock.inert) continue;
|
|
16
|
+
clock.sync(timestamp);
|
|
17
|
+
if (!clock.inert) live = true;
|
|
18
|
+
}
|
|
19
|
+
if (live) loop.request();
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
onResume: () => {
|
|
23
|
+
for (const { clock } of registry.all()) clock.rebase();
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let unwakes: (() => void)[] = [];
|
|
30
|
+
const attach = (): void => {
|
|
31
|
+
for (const off of unwakes) off();
|
|
32
|
+
unwakes = [];
|
|
33
|
+
for (const { clock } of registry.all()) {
|
|
34
|
+
unwakes.push(clock.onWake(() => loop.request()));
|
|
35
|
+
if (!clock.inert) loop.request();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
attach();
|
|
39
|
+
const off = registry.subscribe(attach);
|
|
40
|
+
return () => {
|
|
41
|
+
off();
|
|
42
|
+
for (const unwake of unwakes) unwake();
|
|
43
|
+
};
|
|
44
|
+
}, [registry, loop]);
|
|
45
|
+
}
|
|
@@ -215,6 +215,18 @@ describe('resolveConfigSchema / nested groups', () => {
|
|
|
215
215
|
expect(() => resolveConfigSchema(schema, [])).toThrow(/manual/);
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
+
it('rejects a value leaf whose kind nothing can infer, naming it and the fix', () => {
|
|
219
|
+
const schema = f.schema({ grid: f.group({ when: f.value<number | null>(null) }) });
|
|
220
|
+
expect(() => resolveConfigSchema(schema, [])).toThrow(/grid\.when/);
|
|
221
|
+
expect(() => resolveConfigSchema(schema, [])).toThrow(/f\.custom/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('accepts a kindless value leaf once a lab rule supplies its kind', () => {
|
|
225
|
+
const rule: ConfigRule = (ctx) => (ctx.default === null ? { kind: 'number' } : null);
|
|
226
|
+
const schema = f.schema({ when: f.value<number | null>(null) });
|
|
227
|
+
expect(leafAt(resolveConfigSchema(schema, [rule]), 'when').kind).toBe('number');
|
|
228
|
+
});
|
|
229
|
+
|
|
218
230
|
it('carries autoResolve, unpinned and manual onto the resolved leaf', () => {
|
|
219
231
|
const r = resolveConfigSchema(
|
|
220
232
|
f.schema({
|
package/src/config/resolve.ts
CHANGED
|
@@ -127,6 +127,11 @@ function resolveEntry(
|
|
|
127
127
|
...(entry.kind === null ? {} : { kind: entry.kind }),
|
|
128
128
|
});
|
|
129
129
|
const patch = applyRules(seed, { key, path, default: entry.default }, sink.chain);
|
|
130
|
+
if (patch.kind === undefined) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`[labkit] "${path}" is an f.value whose kind cannot be inferred from its default (${String(entry.default)}) — declare it with a typed builder, or f.custom(kind, default) for a value with no row`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
130
135
|
const { autoResolve, unpinned, manual } = entry.options;
|
|
131
136
|
return {
|
|
132
137
|
...patch,
|
|
@@ -116,7 +116,7 @@ export function ControlMatrix({
|
|
|
116
116
|
inheritHint ?? ((_: ControlMatrixColumn) => (fallback ? `from ${nameOf(fallback)}` : ''));
|
|
117
117
|
const [open, setOpen] = useState<OpenCell | null>(null);
|
|
118
118
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
119
|
-
const { anchor, portalProps } = useOverlayPortal();
|
|
119
|
+
const { anchor, portalProps, ready } = useOverlayPortal();
|
|
120
120
|
|
|
121
121
|
const leafAt = (path: string): PrefLeaf | undefined => {
|
|
122
122
|
const node = schemaNodeAtPath(schema.group, path);
|
|
@@ -195,59 +195,61 @@ export function ControlMatrix({
|
|
|
195
195
|
))}
|
|
196
196
|
</tbody>
|
|
197
197
|
</table>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
198
|
+
{ready && (
|
|
199
|
+
<RACPopover
|
|
200
|
+
triggerRef={triggerRef}
|
|
201
|
+
isOpen={open !== null}
|
|
202
|
+
onOpenChange={(next) => {
|
|
203
|
+
if (!next) setOpen(null);
|
|
204
|
+
}}
|
|
205
|
+
placement="bottom"
|
|
206
|
+
className="lk-control-matrix__popover"
|
|
207
|
+
data-weasel-overlay=""
|
|
208
|
+
{...portalProps}
|
|
209
|
+
>
|
|
210
|
+
{open ? (
|
|
211
|
+
<RACDialog
|
|
212
|
+
className="lk-control-matrix__editor"
|
|
213
|
+
aria-label={`${nameOf(open.column)} ${open.label}`}
|
|
214
|
+
>
|
|
215
|
+
<PropertyList pack="one-up">
|
|
216
|
+
<Editor
|
|
217
|
+
open={open}
|
|
218
|
+
schema={schema}
|
|
219
|
+
config={config}
|
|
220
|
+
setConfig={setConfig}
|
|
221
|
+
close={() => setOpen(null)}
|
|
222
|
+
/>
|
|
223
|
+
</PropertyList>
|
|
224
|
+
{clearable(open.leaf) ? (
|
|
225
|
+
<div className="lk-control-matrix__editor-actions">
|
|
226
|
+
<Button
|
|
227
|
+
size="sm"
|
|
228
|
+
variant="ghost"
|
|
229
|
+
disabled={!valueAtPath(config, open.path) || auto.has(open.path)}
|
|
230
|
+
onClick={() => setConfig(open.path, null)}
|
|
231
|
+
>
|
|
232
|
+
Clear
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
) : null}
|
|
236
|
+
{open.first ? null : (
|
|
237
|
+
<div className="lk-control-matrix__editor-actions">
|
|
238
|
+
<Button
|
|
239
|
+
size="sm"
|
|
240
|
+
variant="ghost"
|
|
241
|
+
disabled={auto.has(open.path)}
|
|
242
|
+
tooltip={`Inherit ${hintFor(open.column)}`}
|
|
243
|
+
onClick={() => setConfig(open.path, autoValue)}
|
|
244
|
+
>
|
|
245
|
+
Inherit
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</RACDialog>
|
|
250
|
+
) : null}
|
|
251
|
+
</RACPopover>
|
|
252
|
+
)}
|
|
251
253
|
</div>
|
|
252
254
|
);
|
|
253
255
|
|
package/src/index.ts
CHANGED
|
@@ -173,6 +173,7 @@ export {
|
|
|
173
173
|
zoomAt,
|
|
174
174
|
} from './canvas';
|
|
175
175
|
export * from './chrome';
|
|
176
|
+
export * from './clock';
|
|
176
177
|
export { type Auto, auto, isAuto } from './config/auto';
|
|
177
178
|
export { autoPathsOf, resolveAutoConfig } from './config/autoConfig';
|
|
178
179
|
export {
|
|
@@ -274,6 +275,7 @@ export * from './job';
|
|
|
274
275
|
export * from './lab';
|
|
275
276
|
export type { LayerListItem, LayerListProps, LayerMove } from './layers';
|
|
276
277
|
export { LayerList, moveLayers } from './layers';
|
|
278
|
+
export * from './lightbox';
|
|
277
279
|
export * from './primitives';
|
|
278
280
|
export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
|
|
279
281
|
export {
|
|
@@ -311,7 +313,6 @@ export type {
|
|
|
311
313
|
TrialInfo,
|
|
312
314
|
TrialRecord,
|
|
313
315
|
TrialStateHandle,
|
|
314
|
-
UndoStack,
|
|
315
316
|
} from './state/types';
|
|
316
317
|
export type { UndockedPanel, UndockedPanels } from './state/undock';
|
|
317
318
|
export { dockPanel, panelKey, undockPanel } from './state/undock';
|
|
@@ -323,5 +324,5 @@ export * from './surface';
|
|
|
323
324
|
export { interstellarTheme } from './theme/interstellar';
|
|
324
325
|
export type { ToolCapability, TrialTool } from './tools/types';
|
|
325
326
|
export * from './trial';
|
|
326
|
-
export type { EventBus, EventListener } from './undo';
|
|
327
|
+
export type { EventBus, EventListener, UndoStack } from './undo';
|
|
327
328
|
export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from './undo';
|
package/src/instrument/types.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
|
|
3
3
|
import type { AnnotationsCapability } from '../annotations/types';
|
|
4
4
|
import type { ViewportSize, WorldSpec } from '../canvas/worldSpec';
|
|
5
5
|
import type { TrialContribution } from '../chrome/types';
|
|
6
|
+
import type { ClockCapability, TrialClock } from '../clock/trialClock';
|
|
6
7
|
import type { ConfigPath, ConfigSchema } from '../config/types';
|
|
7
8
|
import type { ConfigField } from '../controls/types';
|
|
8
9
|
import type { JobCapability, JobHandle } from '../job/types';
|
|
@@ -41,6 +42,8 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
41
42
|
* reading it works over either view.
|
|
42
43
|
*/
|
|
43
44
|
pointer: PointerContextValue;
|
|
45
|
+
/** The trial's clock, present when the instrument declares `clock`. */
|
|
46
|
+
clock?: TrialClock;
|
|
44
47
|
};
|
|
45
48
|
emit: (event: string) => void;
|
|
46
49
|
/** Present only when the instrument declares a `job`. */
|
|
@@ -54,7 +57,21 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
54
57
|
* set `ctx.lineWidth = 1 / zoom` to keep a hairline hairline. */
|
|
55
58
|
export interface CanvasLayer<TS = unknown, TC = unknown> {
|
|
56
59
|
id: string;
|
|
57
|
-
|
|
60
|
+
/** Redraw every frame the trial's clock moves, not only when state, config
|
|
61
|
+
* or the view change. For a layer that reads `elapsed`, `pass` or `phase`. */
|
|
62
|
+
timed?: boolean;
|
|
63
|
+
draw: (
|
|
64
|
+
ctx: CanvasRenderingContext2D,
|
|
65
|
+
args: {
|
|
66
|
+
state: TS;
|
|
67
|
+
config: TC;
|
|
68
|
+
zoom: number;
|
|
69
|
+
/** The trial clock's, as `TrialClock` has them; 0 with no clock. */
|
|
70
|
+
elapsed: number;
|
|
71
|
+
pass: number;
|
|
72
|
+
phase: number;
|
|
73
|
+
},
|
|
74
|
+
) => void;
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
/** Declares that an instrument draws to a canvas: its layers, and where the
|
|
@@ -183,6 +200,9 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
|
|
|
183
200
|
annotations?: AnnotationsCapability<TS, TC>;
|
|
184
201
|
/** Chrome this instrument contributes beyond what its capabilities imply. */
|
|
185
202
|
chrome?: TrialContribution[];
|
|
203
|
+
/** Gives the trial a clock: playback time that layers, renderers and chrome
|
|
204
|
+
* read and drive. */
|
|
205
|
+
clock?: ClockCapability;
|
|
186
206
|
/** Work too slow to do during a render. The runtime starts it, aborts it on
|
|
187
207
|
* unmount and on a `key` change, and renders progress into the trial. */
|
|
188
208
|
job?: JobCapability<TS, TC, TItem>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import { renderSettled } from '@weasel-js/react/testing/renderSettled';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import type { TrialContribution } from '../chrome/types';
|
|
@@ -32,6 +32,13 @@ describe('<Lab> chrome', () => {
|
|
|
32
32
|
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument();
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it('puts the expand toggle in the trial title bar instead of the tile corner', async () => {
|
|
36
|
+
await renderSettled(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
|
|
37
|
+
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull();
|
|
38
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand trial' }));
|
|
39
|
+
expect(document.querySelector('.lk-lightbox--expanded .lk-trial')).not.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
35
42
|
it('suppresses a built-in by id', async () => {
|
|
36
43
|
await renderSettled(
|
|
37
44
|
<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['snapshot']} />,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import { renderSettled } from '@weasel-js/react/testing/renderSettled';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { createMemoryAdapter } from '../state/adapters';
|
|
6
|
+
import { labPrefix } from '../state/labRecords';
|
|
7
|
+
import { Lab, type LabProps } from './Lab';
|
|
8
|
+
import { LabContext, type LabContextValue } from './LabContext';
|
|
9
|
+
import { type Presentation, usePresentation } from './presentation';
|
|
10
|
+
|
|
11
|
+
const stub: Instrument = {
|
|
12
|
+
name: 'Stub',
|
|
13
|
+
defaultConfig: () => ({ count: 0 }),
|
|
14
|
+
initialState: (config) => ({ value: (config as { count: number }).count }),
|
|
15
|
+
render: ({ state }) => <div data-testid="stub-content">{(state as { value: number }).value}</div>,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let presentation: Presentation | null = null;
|
|
19
|
+
let lab: LabContextValue | null = null;
|
|
20
|
+
function Capture() {
|
|
21
|
+
presentation = usePresentation();
|
|
22
|
+
return (
|
|
23
|
+
<LabContext.Consumer>
|
|
24
|
+
{(value) => {
|
|
25
|
+
lab = value;
|
|
26
|
+
return null;
|
|
27
|
+
}}
|
|
28
|
+
</LabContext.Consumer>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mountLab(props: Partial<LabProps> = {}) {
|
|
33
|
+
return renderSettled(
|
|
34
|
+
<Lab {...({ instruments: [stub], defaultInstrument: 'Stub', ...props } as LabProps)}>
|
|
35
|
+
<Capture />
|
|
36
|
+
</Lab>,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const root = (container: HTMLElement) => container.querySelector('.lk-lab') as HTMLElement;
|
|
41
|
+
const presented = (container: HTMLElement) =>
|
|
42
|
+
container.querySelectorAll<HTMLElement>('.lk-trial[data-lk-presented]');
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
presentation = null;
|
|
46
|
+
lab = null;
|
|
47
|
+
history.replaceState(null, '', '/');
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('<Lab present>', () => {
|
|
52
|
+
it('presents one trial opened on the seed', async () => {
|
|
53
|
+
const { container, getByTestId } = await mountLab({
|
|
54
|
+
present: true,
|
|
55
|
+
seed: { config: { count: 4 } },
|
|
56
|
+
});
|
|
57
|
+
expect(root(container).classList.contains('lk-lab--present')).toBe(true);
|
|
58
|
+
expect(presented(container)).toHaveLength(1);
|
|
59
|
+
expect(presented(container)[0]?.querySelector('.lk-trial__stage')).not.toBeNull();
|
|
60
|
+
expect(getByTestId('stub-content').textContent).toBe('4');
|
|
61
|
+
expect(presentation?.active).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('presents from `?present` in the URL', async () => {
|
|
65
|
+
history.replaceState(null, '', '/?present');
|
|
66
|
+
const { container } = await mountLab();
|
|
67
|
+
expect(presented(container)).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('keeps a stored lab presenting under its own key', async () => {
|
|
71
|
+
const storage = createMemoryAdapter();
|
|
72
|
+
const { unmount } = await mountLab({ present: true, storageKey: 'lab', storage });
|
|
73
|
+
await waitFor(async () =>
|
|
74
|
+
expect((await storage.list(labPrefix('lab:present'))).length).toBeGreaterThan(0),
|
|
75
|
+
);
|
|
76
|
+
unmount();
|
|
77
|
+
expect(await storage.list(labPrefix('lab'))).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does not leave on Escape, having nowhere to go back to', async () => {
|
|
81
|
+
const { container } = await mountLab({ present: true });
|
|
82
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
83
|
+
expect(presented(container)).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('warns that a seed is ignored when the lab does not start presenting', async () => {
|
|
87
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
88
|
+
await mountLab({ seed: { config: { count: 4 } } });
|
|
89
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('`seed`'));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('usePresentation', () => {
|
|
94
|
+
it('presents the focused trial and returns on Escape', async () => {
|
|
95
|
+
const { container } = await mountLab();
|
|
96
|
+
await act(async () => lab?.addTrial('Stub', { config: { count: 2 } }));
|
|
97
|
+
await act(async () => lab?.focusTrial(lab.trials[1]?.id as string));
|
|
98
|
+
expect(presented(container)).toHaveLength(0);
|
|
99
|
+
|
|
100
|
+
await act(async () => presentation?.enter());
|
|
101
|
+
expect(presented(container)).toHaveLength(1);
|
|
102
|
+
expect(presented(container)[0]?.dataset.trialId).toBe(lab?.trials[1]?.id);
|
|
103
|
+
|
|
104
|
+
await act(async () => fireEvent.keyDown(document, { key: 'Escape' }));
|
|
105
|
+
expect(presented(container)).toHaveLength(0);
|
|
106
|
+
expect(root(container).classList.contains('lk-lab--present')).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('throws outside <Lab>', () => {
|
|
110
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
111
|
+
expect(() => render(<Capture />)).toThrow(/inside <Lab>/);
|
|
112
|
+
});
|
|
113
|
+
});
|