@weasel-js/labkit 1.7.3 → 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 (197) hide show
  1. package/README.md +24 -0
  2. package/dist/_dts/{CanvasStackContext-Cjy6I-Iv.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
  3. package/dist/_dts/{Stage-D6t7o0pF.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
  4. package/dist/_dts/{index-DCMA4Fy1.d.ts → index-8geQNQet.d.ts} +84 -12
  5. package/dist/_dts/{index-BTmaRy6a.d.ts → index-DRi3V5sN.d.ts} +6 -4
  6. package/dist/_dts/{types-BUBSIZuV.d.ts → types-DUfQ0isU.d.ts} +5 -1
  7. package/dist/_dts/{useTrialState-Bh8bzFgE.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
  8. package/dist/canvas/index.d.ts +5 -5
  9. package/dist/canvas/index.js +2 -2
  10. package/dist/chrome/index.d.ts +3 -3
  11. package/dist/chrome/index.js +7 -7
  12. package/dist/{chunk-HBGH344L.js → chunk-4RCVWX4M.js} +55 -18
  13. package/dist/chunk-4RCVWX4M.js.map +1 -0
  14. package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
  15. package/dist/chunk-6IIKLXBZ.js.map +1 -0
  16. package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
  17. package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
  18. package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
  19. package/dist/chunk-7ALJOVGP.js.map +1 -0
  20. package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
  21. package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
  22. package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
  23. package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
  24. package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
  25. package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
  26. package/dist/{chunk-LPGBC63T.js → chunk-KHB3KRRQ.js} +7 -7
  27. package/dist/chunk-KHB3KRRQ.js.map +1 -0
  28. package/dist/{chunk-IBVRMHW5.js → chunk-NHIO2O3O.js} +31 -30
  29. package/dist/chunk-NHIO2O3O.js.map +1 -0
  30. package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
  31. package/dist/chunk-QMAY74TP.js.map +1 -0
  32. package/dist/{chunk-4X44DMRS.js → chunk-RN7T7OBO.js} +46 -24
  33. package/dist/chunk-RN7T7OBO.js.map +1 -0
  34. package/dist/{chunk-4TJ34FXA.js → chunk-RXO5UEGT.js} +5 -12
  35. package/dist/chunk-RXO5UEGT.js.map +1 -0
  36. package/dist/{chunk-E6NARGU7.js → chunk-SQX35X5V.js} +3 -3
  37. package/dist/{chunk-E6NARGU7.js.map → chunk-SQX35X5V.js.map} +1 -1
  38. package/dist/{chunk-YCFKW5RX.js → chunk-T5OEUNCB.js} +16 -2
  39. package/dist/chunk-T5OEUNCB.js.map +1 -0
  40. package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
  41. package/dist/chunk-UX66USTN.js.map +1 -0
  42. package/dist/{chunk-IRMPZIYU.js → chunk-V4SMGEYY.js} +6732 -6507
  43. package/dist/chunk-V4SMGEYY.js.map +1 -0
  44. package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
  45. package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
  46. package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
  47. package/dist/chunk-XWR2P6MR.js.map +1 -0
  48. package/dist/{chunk-XSJJTXNA.js → chunk-YZA73U4A.js} +19 -19
  49. package/dist/chunk-YZA73U4A.js.map +1 -0
  50. package/dist/config/index.d.ts +12 -5
  51. package/dist/config/index.js +2 -2
  52. package/dist/controls/index.d.ts +2 -2
  53. package/dist/controls/index.js +5 -5
  54. package/dist/dragdrop/index.d.ts +2 -2
  55. package/dist/dragdrop/index.js +2 -2
  56. package/dist/index.d.ts +158 -16
  57. package/dist/index.js +1036 -415
  58. package/dist/index.js.map +1 -1
  59. package/dist/layers/index.d.ts +2 -2
  60. package/dist/layers/index.js +3 -3
  61. package/dist/loupe/index.d.ts +176 -28
  62. package/dist/loupe/index.js +547 -141
  63. package/dist/loupe/index.js.map +1 -1
  64. package/dist/overview/index.d.ts +1 -1
  65. package/dist/overview/index.js +5 -5
  66. package/dist/passthrough/weasel-ui.js +3 -3
  67. package/dist/primitives/index.d.ts +4 -19
  68. package/dist/primitives/index.js +7 -7
  69. package/dist/state/index.d.ts +6 -8
  70. package/dist/state/index.js +5 -5
  71. package/dist/styles.css +181 -44
  72. package/dist/surface/index.d.ts +20 -1
  73. package/dist/surface/index.js +2 -2
  74. package/dist/undo/index.d.ts +19 -8
  75. package/dist/undo/index.js +1 -1
  76. package/package.json +11 -11
  77. package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
  78. package/src/annotations/AnnotationOverlay.tsx +10 -1
  79. package/src/annotations/Annotations.less +2 -0
  80. package/src/annotations/Annotations.overlay.test.tsx +29 -0
  81. package/src/canvas/CanvasStack.test.tsx +35 -0
  82. package/src/canvas/CanvasStack.tsx +12 -1
  83. package/src/canvas/useLayerScheduler.ts +14 -0
  84. package/src/chrome/builtins.test.ts +14 -0
  85. package/src/chrome/builtins.tsx +17 -2
  86. package/src/chrome/regions/StatusRegion.tsx +20 -14
  87. package/src/chrome/regions/regions.test.tsx +28 -0
  88. package/src/chrome/types.ts +8 -0
  89. package/src/clock/clock.browser.test.less +5 -0
  90. package/src/clock/clock.browser.test.tsx +77 -0
  91. package/src/clock/clock.integration.test.tsx +95 -0
  92. package/src/clock/clockRegistry.test.ts +33 -0
  93. package/src/clock/clockRegistry.ts +46 -0
  94. package/src/clock/hooks.ts +44 -0
  95. package/src/clock/index.ts +2 -0
  96. package/src/clock/trialClock.test.ts +265 -0
  97. package/src/clock/trialClock.ts +245 -0
  98. package/src/clock/useClockLoop.ts +45 -0
  99. package/src/config/builder.ts +11 -1
  100. package/src/config/resolve.test.ts +12 -0
  101. package/src/config/resolve.ts +5 -0
  102. package/src/config/types.ts +5 -1
  103. package/src/controls/ControlMatrix.test.tsx +10 -0
  104. package/src/controls/ControlMatrix.tsx +110 -58
  105. package/src/controls/ControlPanel.test.tsx +30 -0
  106. package/src/index.ts +3 -2
  107. package/src/instrument/types.ts +21 -1
  108. package/src/lab/Lab.chrome.test.tsx +8 -1
  109. package/src/lab/Lab.presentation.test.tsx +113 -0
  110. package/src/lab/Lab.surface.test.tsx +20 -9
  111. package/src/lab/Lab.tsx +132 -257
  112. package/src/lab/LabLightbox.browser.test.less +17 -0
  113. package/src/lab/LabLightbox.browser.test.tsx +216 -0
  114. package/src/lab/LabShell.less +10 -3
  115. package/src/lab/LabSurface.tsx +129 -0
  116. package/src/lab/Workspace.stories.tsx +18 -0
  117. package/src/lab/Workspace.surface.test.tsx +4 -0
  118. package/src/lab/Workspace.test.tsx +37 -1
  119. package/src/lab/Workspace.tsx +34 -6
  120. package/src/lab/index.ts +2 -0
  121. package/src/lab/nebula.ts +30 -0
  122. package/src/lab/openLab.test.ts +81 -0
  123. package/src/lab/openLab.ts +119 -0
  124. package/src/lab/presentation.browser.test.less +5 -0
  125. package/src/lab/presentation.browser.test.tsx +119 -0
  126. package/src/lab/presentation.less +64 -0
  127. package/src/lab/presentation.tsx +47 -0
  128. package/src/lab/useFocusPick.ts +43 -0
  129. package/src/lightbox/Lightbox.browser.test.less +14 -0
  130. package/src/lightbox/Lightbox.browser.test.tsx +113 -0
  131. package/src/lightbox/Lightbox.less +128 -0
  132. package/src/lightbox/Lightbox.test.tsx +195 -0
  133. package/src/lightbox/Lightbox.tsx +205 -0
  134. package/src/lightbox/LightboxContext.ts +38 -0
  135. package/src/lightbox/LightboxLayers.tsx +40 -0
  136. package/src/lightbox/index.ts +8 -0
  137. package/src/lightbox/modal.ts +94 -0
  138. package/src/loupe/AGENTS.md +40 -2
  139. package/src/loupe/CanvasLoupe.tsx +5 -4
  140. package/src/loupe/DomLoupe.tsx +7 -5
  141. package/src/loupe/Loupe.less +15 -0
  142. package/src/loupe/LoupeBubble.tsx +35 -13
  143. package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
  144. package/src/loupe/SourceLoupe.tsx +60 -0
  145. package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
  146. package/src/loupe/TrialLoupe.tsx +126 -28
  147. package/src/loupe/canvasLens.ts +20 -17
  148. package/src/loupe/index.ts +26 -2
  149. package/src/loupe/sourceLens.test.ts +65 -0
  150. package/src/loupe/sourceLens.ts +97 -0
  151. package/src/loupe/types.test.ts +6 -0
  152. package/src/loupe/types.ts +109 -7
  153. package/src/loupe/useLoupe.test.tsx +188 -3
  154. package/src/loupe/useLoupe.ts +106 -11
  155. package/src/primitives/index.ts +2 -2
  156. package/src/specimen/Specimen.tsx +0 -7
  157. package/src/state/helpers.test.ts +4 -11
  158. package/src/state/helpers.ts +2 -8
  159. package/src/state/index.ts +0 -2
  160. package/src/state/openLabStore.test.ts +10 -8
  161. package/src/state/openLabStore.ts +1 -1
  162. package/src/state/store.test.ts +2 -2
  163. package/src/state/store.ts +15 -16
  164. package/src/state/types.ts +9 -9
  165. package/src/styles.less +2 -1
  166. package/src/surface/AGENTS.md +13 -0
  167. package/src/surface/index.ts +1 -0
  168. package/src/surface/useSurfaceTile.test.tsx +4 -0
  169. package/src/surface/useSurfaceTile.ts +17 -1
  170. package/src/surface/useTiledSurface.test.tsx +65 -0
  171. package/src/surface/useTiledSurface.ts +68 -1
  172. package/src/theme/interstellar.theme.json +0 -14
  173. package/src/trial/Trial.stories.tsx +0 -1
  174. package/src/trial/Trial.test.tsx +40 -3
  175. package/src/trial/Trial.tsx +87 -43
  176. package/src/trial/TrialChrome.tsx +20 -2
  177. package/src/trial/trialOps.test.ts +1 -2
  178. package/src/trial/trialOps.ts +2 -3
  179. package/src/undo/stateOp.ts +21 -0
  180. package/src/undo/undoStack.ts +18 -6
  181. package/dist/chunk-4TJ34FXA.js.map +0 -1
  182. package/dist/chunk-4X44DMRS.js.map +0 -1
  183. package/dist/chunk-EESJUSYR.js.map +0 -1
  184. package/dist/chunk-HBGH344L.js.map +0 -1
  185. package/dist/chunk-IBVRMHW5.js.map +0 -1
  186. package/dist/chunk-IRMPZIYU.js.map +0 -1
  187. package/dist/chunk-LPGBC63T.js.map +0 -1
  188. package/dist/chunk-PMLZP7RT.js.map +0 -1
  189. package/dist/chunk-RL2LOLNI.js.map +0 -1
  190. package/dist/chunk-T6DLGEJB.js.map +0 -1
  191. package/dist/chunk-WIG6XHJ7.js.map +0 -1
  192. package/dist/chunk-XSJJTXNA.js.map +0 -1
  193. package/dist/chunk-YCFKW5RX.js.map +0 -1
  194. package/src/primitives/StatusBar.less +0 -34
  195. package/src/primitives/StatusBar.stories.tsx +0 -24
  196. package/src/primitives/StatusBar.test.tsx +0 -25
  197. 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
+ }
@@ -1,4 +1,4 @@
1
- import type { Display } from '@weasel-js/quantity';
1
+ import type { Display, InfinityText } from '@weasel-js/quantity';
2
2
  import type { PrefLeaf, PrefNumberUnit } from '@weasel-js/ui';
3
3
  import { type Auto, isAuto } from './auto';
4
4
  import type {
@@ -159,6 +159,16 @@ export class NumberNode extends BaseNode<number> {
159
159
  return this.ann({ unit });
160
160
  }
161
161
 
162
+ /**
163
+ * Make the top of the range mean infinity: the slider's `max` stop stores
164
+ * `Infinity` and reads `word` — `.range(0, 5000).endless('never')`. A value
165
+ * of `Infinity` sits at that stop. `at: 'min'` puts it at the bottom
166
+ * instead, as `-Infinity`; `'both'` does both.
167
+ */
168
+ endless(word?: InfinityText, at: 'min' | 'max' | 'both' = 'max'): this {
169
+ return this.ann(word === undefined ? { endless: at } : { endless: at, infinity: word });
170
+ }
171
+
162
172
  /** Force a slider even without both bounds. */
163
173
  slider(): this {
164
174
  return this.ann({ control: 'slider' });
@@ -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,
@@ -1,4 +1,4 @@
1
- import type { Display } from '@weasel-js/quantity';
1
+ import type { Display, InfinityText } from '@weasel-js/quantity';
2
2
  import type { PrefGroup, PrefLeaf, PrefNumberUnit, PrefRenderer } from '@weasel-js/ui';
3
3
  import type { ReactNode } from 'react';
4
4
 
@@ -36,6 +36,10 @@ export interface Annotations {
36
36
  /** Display-unit conversion for a number stored in a canonical unit — the
37
37
  * value, its bounds and its step all convert at the control's edge. */
38
38
  unit?: PrefNumberUnit;
39
+ /** Which end of a number's slider stands for infinity. */
40
+ endless?: 'min' | 'max' | 'both';
41
+ /** The word a number's infinity shows as — `'never'`. Default `∞`. */
42
+ infinity?: InfinityText;
39
43
  /** A color leaf's value carries alpha (`#rrggbbaa`) and its row edits it. */
40
44
  alpha?: boolean;
41
45
  control?: string;
@@ -158,6 +158,16 @@ describe('<ControlMatrix>', () => {
158
158
  expect(setConfig).toHaveBeenCalledWith('looks.window.glow', 0.75);
159
159
  });
160
160
 
161
+ it('lists an enum cell’s options in its popover, with no dropdown inside, and closes on a choice', () => {
162
+ const { setConfig } = setup();
163
+ fireEvent.click(cell('Window Shading'));
164
+ const dialog = screen.getByRole('dialog', { name: 'Window Shading' });
165
+ expect(within(dialog).queryByRole('button', { name: /Shading/ })).toBeNull();
166
+ fireEvent.click(within(dialog).getByRole('radio', { name: 'Soft' }));
167
+ expect(setConfig).toHaveBeenCalledWith('looks.window.shading', 'soft');
168
+ expect(screen.queryByRole('dialog')).toBeNull();
169
+ });
170
+
161
171
  it('unpins from the popover with Inherit, disabled while already inherited', () => {
162
172
  const { setConfig, unmount } = setup();
163
173
  fireEvent.click(cell('Window Glow'));
@@ -5,6 +5,7 @@ import {
5
5
  isPrefLeaf,
6
6
  type PrefLeaf,
7
7
  type PropertyControlProps,
8
+ PropertyField,
8
9
  PropertyHelp,
9
10
  PropertyList,
10
11
  PropertyPanel,
@@ -115,7 +116,7 @@ export function ControlMatrix({
115
116
  inheritHint ?? ((_: ControlMatrixColumn) => (fallback ? `from ${nameOf(fallback)}` : ''));
116
117
  const [open, setOpen] = useState<OpenCell | null>(null);
117
118
  const triggerRef = useRef<HTMLElement | null>(null);
118
- const { anchor, portalProps } = useOverlayPortal();
119
+ const { anchor, portalProps, ready } = useOverlayPortal();
119
120
 
120
121
  const leafAt = (path: string): PrefLeaf | undefined => {
121
122
  const node = schemaNodeAtPath(schema.group, path);
@@ -194,63 +195,61 @@ export function ControlMatrix({
194
195
  ))}
195
196
  </tbody>
196
197
  </table>
197
- <RACPopover
198
- triggerRef={triggerRef}
199
- isOpen={open !== null}
200
- onOpenChange={(next) => {
201
- if (!next) setOpen(null);
202
- }}
203
- placement="bottom"
204
- className="lk-control-matrix__popover"
205
- data-weasel-overlay=""
206
- {...portalProps}
207
- >
208
- {open ? (
209
- <RACDialog
210
- className="lk-control-matrix__editor"
211
- aria-label={`${nameOf(open.column)} ${open.label}`}
212
- >
213
- <PropertyList pack="one-up">
214
- <ControlRow
215
- path={open.path}
216
- leaf={open.leaf}
217
- resolved={schema}
218
- config={config}
219
- shown={config}
220
- setConfig={setConfig}
221
- pack="one-up"
222
- auto={NO_AUTO}
223
- setRowAuto={noop}
224
- />
225
- </PropertyList>
226
- {clearable(open.leaf) ? (
227
- <div className="lk-control-matrix__editor-actions">
228
- <Button
229
- size="sm"
230
- variant="ghost"
231
- disabled={!valueAtPath(config, open.path) || auto.has(open.path)}
232
- onClick={() => setConfig(open.path, null)}
233
- >
234
- Clear
235
- </Button>
236
- </div>
237
- ) : null}
238
- {open.first ? null : (
239
- <div className="lk-control-matrix__editor-actions">
240
- <Button
241
- size="sm"
242
- variant="ghost"
243
- disabled={auto.has(open.path)}
244
- tooltip={`Inherit ${hintFor(open.column)}`}
245
- onClick={() => setConfig(open.path, autoValue)}
246
- >
247
- Inherit
248
- </Button>
249
- </div>
250
- )}
251
- </RACDialog>
252
- ) : null}
253
- </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
+ )}
254
253
  </div>
255
254
  );
256
255
 
@@ -262,6 +261,59 @@ export function ControlMatrix({
262
261
  );
263
262
  }
264
263
 
264
+ /**
265
+ * The control a cell opens. An enum lists its options outright, as radios: the
266
+ * panel's dropdown would be a second popup opened from inside this one.
267
+ * Choosing one is the whole edit, so it closes the popover.
268
+ */
269
+ function Editor({
270
+ open,
271
+ schema,
272
+ config,
273
+ setConfig,
274
+ close,
275
+ }: {
276
+ open: OpenCell;
277
+ schema: ResolvedConfig;
278
+ config: Record<string, unknown>;
279
+ setConfig: (path: string, value: unknown) => void;
280
+ close: () => void;
281
+ }) {
282
+ const { path, leaf } = open;
283
+ const field = isBuiltinToolPref(leaf)
284
+ ? prefFieldProps(leaf, {
285
+ value: valueAtPath(config, path) ?? leaf.default,
286
+ setValue: (next) => setConfig(path, next),
287
+ })
288
+ : null;
289
+ if (field?.kind === 'enum') {
290
+ return (
291
+ <PropertyField
292
+ {...field}
293
+ label={leaf.name}
294
+ control="radio"
295
+ onChange={(next: Parameters<typeof field.onChange>[0]) => {
296
+ field.onChange(next);
297
+ close();
298
+ }}
299
+ />
300
+ );
301
+ }
302
+ return (
303
+ <ControlRow
304
+ path={path}
305
+ leaf={leaf}
306
+ resolved={schema}
307
+ config={config}
308
+ shown={config}
309
+ setConfig={setConfig}
310
+ pack="one-up"
311
+ auto={NO_AUTO}
312
+ setRowAuto={noop}
313
+ />
314
+ );
315
+ }
316
+
265
317
  function ColumnHeader({
266
318
  column,
267
319
  onClick,
@@ -1188,3 +1188,33 @@ describe('<ControlPanel> resettable group', () => {
1188
1188
  ]);
1189
1189
  });
1190
1190
  });
1191
+
1192
+ describe('<ControlPanel> endless number', () => {
1193
+ it('reads its word at the top of the range and writes Infinity there', () => {
1194
+ const schema = resolveConfigSchema(
1195
+ f.schema({ cut: f.number(Infinity).range(0, 5000).step(50).suffix('ms').endless('never') }),
1196
+ [],
1197
+ );
1198
+ const setConfig = vi.fn();
1199
+ function Harness() {
1200
+ const [config, setState] = useState<Record<string, unknown>>({ cut: Infinity });
1201
+ return (
1202
+ <ControlPanel
1203
+ schema={schema}
1204
+ config={config}
1205
+ setConfig={(key: string, value: unknown) => {
1206
+ setConfig(key, value);
1207
+ setState((c) => ({ ...c, [key]: value }));
1208
+ }}
1209
+ />
1210
+ );
1211
+ }
1212
+ render(<Harness />);
1213
+ expect(screen.getByRole('spinbutton')).toHaveValue('never');
1214
+ expect(screen.queryByText('ms')).toBeNull();
1215
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '4950' } });
1216
+ expect(setConfig).toHaveBeenLastCalledWith('cut', 4950);
1217
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '5000' } });
1218
+ expect(setConfig).toHaveBeenLastCalledWith('cut', Infinity);
1219
+ });
1220
+ });
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';