@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.
Files changed (189) hide show
  1. package/README.md +24 -0
  2. package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
  3. package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
  4. package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
  5. package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
  6. package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
  7. package/dist/canvas/index.d.ts +4 -4
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +2 -2
  10. package/dist/chrome/index.js +7 -7
  11. package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
  12. package/dist/chunk-4RCVWX4M.js.map +1 -0
  13. package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
  14. package/dist/chunk-6IIKLXBZ.js.map +1 -0
  15. package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
  16. package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
  17. package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
  18. package/dist/chunk-7ALJOVGP.js.map +1 -0
  19. package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
  20. package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
  21. package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
  22. package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
  23. package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
  24. package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
  25. package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
  26. package/dist/chunk-KHB3KRRQ.js.map +1 -0
  27. package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
  28. package/dist/chunk-NHIO2O3O.js.map +1 -0
  29. package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
  30. package/dist/chunk-QMAY74TP.js.map +1 -0
  31. package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
  32. package/dist/chunk-RN7T7OBO.js.map +1 -0
  33. package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
  34. package/dist/chunk-RXO5UEGT.js.map +1 -0
  35. package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
  36. package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
  37. package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
  38. package/dist/chunk-T5OEUNCB.js.map +1 -0
  39. package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
  40. package/dist/chunk-UX66USTN.js.map +1 -0
  41. package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
  42. package/dist/chunk-V4SMGEYY.js.map +1 -0
  43. package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
  44. package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
  45. package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
  46. package/dist/chunk-XWR2P6MR.js.map +1 -0
  47. package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
  48. package/dist/chunk-YZA73U4A.js.map +1 -0
  49. package/dist/config/index.d.ts +2 -2
  50. package/dist/config/index.js +2 -2
  51. package/dist/controls/index.js +5 -5
  52. package/dist/dragdrop/index.d.ts +1 -1
  53. package/dist/dragdrop/index.js +2 -2
  54. package/dist/index.d.ts +157 -15
  55. package/dist/index.js +893 -258
  56. package/dist/index.js.map +1 -1
  57. package/dist/layers/index.d.ts +1 -1
  58. package/dist/layers/index.js +3 -3
  59. package/dist/loupe/index.d.ts +175 -27
  60. package/dist/loupe/index.js +547 -141
  61. package/dist/loupe/index.js.map +1 -1
  62. package/dist/overview/index.js +5 -5
  63. package/dist/passthrough/weasel-ui.js +3 -3
  64. package/dist/primitives/index.d.ts +4 -19
  65. package/dist/primitives/index.js +7 -7
  66. package/dist/state/index.d.ts +5 -7
  67. package/dist/state/index.js +5 -5
  68. package/dist/styles.css +173 -38
  69. package/dist/surface/index.d.ts +20 -1
  70. package/dist/surface/index.js +2 -2
  71. package/dist/undo/index.d.ts +18 -7
  72. package/dist/undo/index.js +1 -1
  73. package/package.json +11 -11
  74. package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
  75. package/src/annotations/AnnotationOverlay.tsx +10 -1
  76. package/src/annotations/Annotations.less +2 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +29 -0
  78. package/src/canvas/CanvasStack.test.tsx +35 -0
  79. package/src/canvas/CanvasStack.tsx +12 -1
  80. package/src/canvas/useLayerScheduler.ts +14 -0
  81. package/src/chrome/builtins.test.ts +14 -0
  82. package/src/chrome/builtins.tsx +17 -2
  83. package/src/chrome/regions/StatusRegion.tsx +20 -14
  84. package/src/chrome/regions/regions.test.tsx +28 -0
  85. package/src/chrome/types.ts +8 -0
  86. package/src/clock/clock.browser.test.less +5 -0
  87. package/src/clock/clock.browser.test.tsx +77 -0
  88. package/src/clock/clock.integration.test.tsx +95 -0
  89. package/src/clock/clockRegistry.test.ts +33 -0
  90. package/src/clock/clockRegistry.ts +46 -0
  91. package/src/clock/hooks.ts +44 -0
  92. package/src/clock/index.ts +2 -0
  93. package/src/clock/trialClock.test.ts +265 -0
  94. package/src/clock/trialClock.ts +245 -0
  95. package/src/clock/useClockLoop.ts +45 -0
  96. package/src/config/resolve.test.ts +12 -0
  97. package/src/config/resolve.ts +5 -0
  98. package/src/controls/ControlMatrix.tsx +56 -54
  99. package/src/index.ts +3 -2
  100. package/src/instrument/types.ts +21 -1
  101. package/src/lab/Lab.chrome.test.tsx +8 -1
  102. package/src/lab/Lab.presentation.test.tsx +113 -0
  103. package/src/lab/Lab.surface.test.tsx +20 -9
  104. package/src/lab/Lab.tsx +132 -257
  105. package/src/lab/LabLightbox.browser.test.less +17 -0
  106. package/src/lab/LabLightbox.browser.test.tsx +216 -0
  107. package/src/lab/LabShell.less +10 -3
  108. package/src/lab/LabSurface.tsx +129 -0
  109. package/src/lab/Workspace.stories.tsx +18 -0
  110. package/src/lab/Workspace.surface.test.tsx +4 -0
  111. package/src/lab/Workspace.test.tsx +37 -1
  112. package/src/lab/Workspace.tsx +34 -6
  113. package/src/lab/index.ts +2 -0
  114. package/src/lab/nebula.ts +30 -0
  115. package/src/lab/openLab.test.ts +81 -0
  116. package/src/lab/openLab.ts +119 -0
  117. package/src/lab/presentation.browser.test.less +5 -0
  118. package/src/lab/presentation.browser.test.tsx +119 -0
  119. package/src/lab/presentation.less +64 -0
  120. package/src/lab/presentation.tsx +47 -0
  121. package/src/lab/useFocusPick.ts +43 -0
  122. package/src/lightbox/Lightbox.browser.test.less +14 -0
  123. package/src/lightbox/Lightbox.browser.test.tsx +113 -0
  124. package/src/lightbox/Lightbox.less +128 -0
  125. package/src/lightbox/Lightbox.test.tsx +195 -0
  126. package/src/lightbox/Lightbox.tsx +205 -0
  127. package/src/lightbox/LightboxContext.ts +38 -0
  128. package/src/lightbox/LightboxLayers.tsx +40 -0
  129. package/src/lightbox/index.ts +8 -0
  130. package/src/lightbox/modal.ts +94 -0
  131. package/src/loupe/AGENTS.md +40 -2
  132. package/src/loupe/CanvasLoupe.tsx +5 -4
  133. package/src/loupe/DomLoupe.tsx +7 -5
  134. package/src/loupe/Loupe.less +15 -0
  135. package/src/loupe/LoupeBubble.tsx +35 -13
  136. package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
  137. package/src/loupe/SourceLoupe.tsx +60 -0
  138. package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
  139. package/src/loupe/TrialLoupe.tsx +126 -28
  140. package/src/loupe/canvasLens.ts +20 -17
  141. package/src/loupe/index.ts +26 -2
  142. package/src/loupe/sourceLens.test.ts +65 -0
  143. package/src/loupe/sourceLens.ts +97 -0
  144. package/src/loupe/types.test.ts +6 -0
  145. package/src/loupe/types.ts +109 -7
  146. package/src/loupe/useLoupe.test.tsx +188 -3
  147. package/src/loupe/useLoupe.ts +106 -11
  148. package/src/primitives/index.ts +2 -2
  149. package/src/specimen/Specimen.tsx +0 -7
  150. package/src/state/helpers.test.ts +4 -11
  151. package/src/state/helpers.ts +2 -8
  152. package/src/state/index.ts +0 -2
  153. package/src/state/openLabStore.test.ts +10 -8
  154. package/src/state/openLabStore.ts +1 -1
  155. package/src/state/store.test.ts +2 -2
  156. package/src/state/store.ts +15 -16
  157. package/src/state/types.ts +9 -9
  158. package/src/styles.less +2 -1
  159. package/src/surface/AGENTS.md +13 -0
  160. package/src/surface/index.ts +1 -0
  161. package/src/surface/useSurfaceTile.test.tsx +4 -0
  162. package/src/surface/useSurfaceTile.ts +17 -1
  163. package/src/surface/useTiledSurface.test.tsx +65 -0
  164. package/src/surface/useTiledSurface.ts +68 -1
  165. package/src/trial/Trial.stories.tsx +0 -1
  166. package/src/trial/Trial.test.tsx +40 -3
  167. package/src/trial/Trial.tsx +87 -43
  168. package/src/trial/TrialChrome.tsx +20 -2
  169. package/src/trial/trialOps.test.ts +1 -2
  170. package/src/trial/trialOps.ts +2 -3
  171. package/src/undo/stateOp.ts +21 -0
  172. package/src/undo/undoStack.ts +18 -6
  173. package/dist/chunk-3L4MLLQB.js.map +0 -1
  174. package/dist/chunk-7KAETQSS.js.map +0 -1
  175. package/dist/chunk-EESJUSYR.js.map +0 -1
  176. package/dist/chunk-J5HJJDLF.js.map +0 -1
  177. package/dist/chunk-NVKBL7ZE.js.map +0 -1
  178. package/dist/chunk-PMLZP7RT.js.map +0 -1
  179. package/dist/chunk-RL2LOLNI.js.map +0 -1
  180. package/dist/chunk-T6DLGEJB.js.map +0 -1
  181. package/dist/chunk-WIG6XHJ7.js.map +0 -1
  182. package/dist/chunk-WURZTJGQ.js.map +0 -1
  183. package/dist/chunk-XXACAPVU.js.map +0 -1
  184. package/dist/chunk-YDE6XATS.js.map +0 -1
  185. package/dist/chunk-YX75W6EK.js.map +0 -1
  186. package/src/primitives/StatusBar.less +0 -34
  187. package/src/primitives/StatusBar.stories.tsx +0 -24
  188. package/src/primitives/StatusBar.test.tsx +0 -25
  189. 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({
@@ -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
- <RACPopover
199
- triggerRef={triggerRef}
200
- isOpen={open !== null}
201
- onOpenChange={(next) => {
202
- if (!next) setOpen(null);
203
- }}
204
- placement="bottom"
205
- className="lk-control-matrix__popover"
206
- data-weasel-overlay=""
207
- {...portalProps}
208
- >
209
- {open ? (
210
- <RACDialog
211
- className="lk-control-matrix__editor"
212
- aria-label={`${nameOf(open.column)} ${open.label}`}
213
- >
214
- <PropertyList pack="one-up">
215
- <Editor
216
- open={open}
217
- schema={schema}
218
- config={config}
219
- setConfig={setConfig}
220
- close={() => setOpen(null)}
221
- />
222
- </PropertyList>
223
- {clearable(open.leaf) ? (
224
- <div className="lk-control-matrix__editor-actions">
225
- <Button
226
- size="sm"
227
- variant="ghost"
228
- disabled={!valueAtPath(config, open.path) || auto.has(open.path)}
229
- onClick={() => setConfig(open.path, null)}
230
- >
231
- Clear
232
- </Button>
233
- </div>
234
- ) : null}
235
- {open.first ? null : (
236
- <div className="lk-control-matrix__editor-actions">
237
- <Button
238
- size="sm"
239
- variant="ghost"
240
- disabled={auto.has(open.path)}
241
- tooltip={`Inherit ${hintFor(open.column)}`}
242
- onClick={() => setConfig(open.path, autoValue)}
243
- >
244
- Inherit
245
- </Button>
246
- </div>
247
- )}
248
- </RACDialog>
249
- ) : null}
250
- </RACPopover>
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';
@@ -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
- draw: (ctx: CanvasRenderingContext2D, args: { state: TS; config: TC; zoom: number }) => void;
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
+ });