@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
@@ -8,7 +8,7 @@ import {
8
8
  import { type RefObject, useCallback, useEffect, useReducer, useRef } from 'react';
9
9
  import type { LoupeInputApi } from './loupeActions';
10
10
  import { WHEEL_RATE } from './loupeActions';
11
- import type { ResolvedLoupe } from './types';
11
+ import type { LoupeLens, ResolvedLoupe } from './types';
12
12
 
13
13
  /** Options for {@link useLoupe}. */
14
14
  export interface UseLoupeOptions {
@@ -20,6 +20,12 @@ export interface UseLoupeOptions {
20
20
  /** Hex color at a host point. Omitted, the loupe reports no color — which
21
21
  * is the honest answer for a surface with no pixels to read. */
22
22
  sample?: (p: LoupePoint) => string | null;
23
+ /** Run `fn` whenever what `sample` reads changes without the aim moving — a
24
+ * canvas that animates, or a frame that arrives after the aim did; returns
25
+ * an unsubscribe. While the lens is up, each call samples the aim again, on
26
+ * top of the sample every aim takes at once. Omitted, color changes only on
27
+ * an aim. */
28
+ subscribeResample?: (fn: () => void) => () => void;
23
29
  }
24
30
 
25
31
  /** A loupe as a React view reads it. */
@@ -28,11 +34,16 @@ export interface LoupeState {
28
34
  visible: boolean;
29
35
  /** Where it is aimed, in the host's own pixels. */
30
36
  aim: LoupePoint;
37
+ /** The wheel's magnification. What the lens shows at is `lens.factor`. */
31
38
  factor: number;
39
+ /** The box the lens is drawn in, and what it shows. */
40
+ lens: LoupeLens;
32
41
  mode: LoupeMode;
33
42
  color: string | null;
34
43
  setMode: (mode: LoupeMode) => void;
35
44
  setFactor: (factor: number) => void;
45
+ /** Sample the aim again, for pixels that changed under a still aim. */
46
+ resample: () => void;
36
47
  /** Sample what the lens shows at a point inside it. */
37
48
  pick: (p?: LoupePoint) => string | null;
38
49
  /** What `<LoupeGestures>` drives the lens through. Stable for the life of
@@ -56,10 +67,19 @@ function lensShown(over: boolean, enabled: boolean, peeking: boolean): boolean {
56
67
  * has no continuous-motion entry, and inventing one to carry the lens' aim
57
68
  * would be an input taxonomy change, not a loupe change.
58
69
  */
59
- export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions): LoupeState {
70
+ export function useLoupe({
71
+ options,
72
+ hostRef,
73
+ enabled,
74
+ sample,
75
+ subscribeResample,
76
+ }: UseLoupeOptions): LoupeState {
60
77
  const [, bump] = useReducer((n: number) => n + 1, 0);
61
78
 
62
79
  const overRef = useRef(false);
80
+ // Where the pointer last was over the host, in client px. The model ignores
81
+ // aims while the lens is down, so this is what the lens opens at.
82
+ const pointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
63
83
  const peekingRef = useRef(false);
64
84
  const enabledRef = useLatest(enabled);
65
85
  const sampleRef = useLatest(sample);
@@ -81,9 +101,15 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
81
101
  onColorChange: (hex) => onColorChangeRef.current?.(hex),
82
102
  surface: {
83
103
  lens: () => {
84
- const d = diameterRef.current;
85
- const { x, y } = modelRef.current?.aim ?? { x: 0, y: 0 };
86
- return { x: x - d / 2, y: y - d / 2, w: d, h: d };
104
+ const { center, shows, width, height, factor } = lensRef.current;
105
+ return {
106
+ x: center.x - width / 2,
107
+ y: center.y - height / 2,
108
+ w: width,
109
+ h: height,
110
+ shows,
111
+ factor,
112
+ };
87
113
  },
88
114
  // The lens follows the pointer and is painted over the host rather than
89
115
  // into it, so it is never part of the picture being magnified.
@@ -97,7 +123,6 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
97
123
  }
98
124
  const model = modelRef.current;
99
125
 
100
- const diameterRef = useLatest(options.diameter);
101
126
  const goneRef = useRef(false);
102
127
 
103
128
  // The model holds no resources, and `dispose` is one-way — so unmounting only
@@ -111,11 +136,25 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
111
136
  };
112
137
  }, []);
113
138
 
139
+ // Aim at the pointer's last spot over the host, for a lens that has just
140
+ // come up under a pointer that has not moved since. Stable, because `input`
141
+ // captures it once.
142
+ const hostRefRef = useLatest(hostRef);
143
+ const aimAtPointer = useCallback((): void => {
144
+ const host = hostRefRef.current.current;
145
+ const p = pointerRef.current;
146
+ if (!host || !p) return;
147
+ const rect = host.getBoundingClientRect();
148
+ modelRef.current?.aimAt({ x: p.clientX - rect.left, y: p.clientY - rect.top });
149
+ }, [hostRefRef]);
150
+
114
151
  // Turning the loupe off with the pointer still inside must put the lens away,
115
- // and the model only reconsiders on an aim.
152
+ // and the model only reconsiders on an aim; turning it on must open the lens
153
+ // where the pointer already is.
116
154
  useEffect(() => {
117
- if (!enabled && !peekingRef.current) bump();
118
- }, [enabled]);
155
+ if (enabled) aimAtPointer();
156
+ else if (!peekingRef.current) bump();
157
+ }, [enabled, aimAtPointer]);
119
158
 
120
159
  useEffect(() => {
121
160
  const host = hostRef.current;
@@ -123,6 +162,7 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
123
162
 
124
163
  const onPointerMove = (e: PointerEvent): void => {
125
164
  overRef.current = true;
165
+ pointerRef.current = { clientX: e.clientX, clientY: e.clientY };
126
166
  const rect = host.getBoundingClientRect();
127
167
  model.aimAt({ x: e.clientX - rect.left, y: e.clientY - rect.top });
128
168
  if (!shown()) return;
@@ -130,6 +170,7 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
130
170
  };
131
171
  const onPointerLeave = (): void => {
132
172
  overRef.current = false;
173
+ pointerRef.current = null;
133
174
  bump();
134
175
  };
135
176
 
@@ -141,6 +182,58 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
141
182
  };
142
183
  }, [hostRef, model, shown]);
143
184
 
185
+ // Subscribed while the lens is up, and before any effect the caller declares
186
+ // after this hook runs — so a frame its own effect provokes is not missed.
187
+ // This render's `enabled`: `enabledRef` holds the last committed one.
188
+ const visible = lensShown(overRef.current, enabled, peekingRef.current);
189
+
190
+ // Asked only while the lens is up: a host's `place` may measure its layout.
191
+ const placed = visible
192
+ ? (options.place?.({ aim: model.aim, factor: model.factor }) ?? null)
193
+ : null;
194
+ const lens: LoupeLens = placed
195
+ ? {
196
+ center: placed.center,
197
+ shows: placed.shows ?? placed.center,
198
+ width: placed.width,
199
+ height: placed.height,
200
+ factor: placed.factor ?? model.factor,
201
+ shape: options.shape,
202
+ }
203
+ : {
204
+ center: model.aim,
205
+ shows: model.aim,
206
+ width: options.diameter,
207
+ height: options.diameter,
208
+ factor: model.factor,
209
+ shape: options.shape,
210
+ };
211
+ const lensRef = useLatest(lens);
212
+
213
+ // Told on a change in value, not on every render: a host drawing into this
214
+ // box would otherwise redraw for each one.
215
+ const onLensRef = useLatest(options.onLens);
216
+ const reportedRef = useRef<string>(JSON.stringify(null));
217
+ const report = useCallback(
218
+ (next: LoupeLens | null): void => {
219
+ const key = JSON.stringify(next);
220
+ if (key === reportedRef.current) return;
221
+ reportedRef.current = key;
222
+ onLensRef.current?.(next);
223
+ },
224
+ [onLensRef],
225
+ );
226
+ const shownLens = visible ? lens : null;
227
+ const shownKey = JSON.stringify(shownLens);
228
+ useEffect(() => {
229
+ report(JSON.parse(shownKey) as LoupeLens | null);
230
+ }, [shownKey, report]);
231
+ useEffect(() => () => report(null), [report]);
232
+ useEffect(() => {
233
+ if (!visible || !subscribeResample) return;
234
+ return subscribeResample(() => model.resample());
235
+ }, [visible, subscribeResample, model]);
236
+
144
237
  const inputRef = useRef<LoupeInputApi | null>(null);
145
238
  if (inputRef.current === null) {
146
239
  inputRef.current = {
@@ -148,6 +241,7 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
148
241
  setPeeking: (on) => {
149
242
  if (peekingRef.current === on) return;
150
243
  peekingRef.current = on;
244
+ if (on) aimAtPointer();
151
245
  bump();
152
246
  },
153
247
  magnifyBy: (deltaY) => {
@@ -158,14 +252,15 @@ export function useLoupe({ options, hostRef, enabled, sample }: UseLoupeOptions)
158
252
  }
159
253
 
160
254
  return {
161
- // This render's `enabled`: `enabledRef` holds the last committed one.
162
- visible: lensShown(overRef.current, enabled, peekingRef.current),
255
+ visible,
163
256
  aim: model.aim,
164
257
  factor: model.factor,
258
+ lens,
165
259
  mode: model.mode,
166
260
  color: model.color,
167
261
  setMode: model.setMode,
168
262
  setFactor: model.setFactor,
263
+ resample: model.resample,
169
264
  pick: model.pick,
170
265
  input: inputRef.current,
171
266
  };
@@ -1,3 +1,5 @@
1
+ export type { StatusBarItemProps, StatusBarProps } from '@weasel-js/ui';
2
+ export { StatusBar, StatusBarItem, StatusBarSpacer } from '@weasel-js/ui';
1
3
  export type { FloatingPanelProps } from './FloatingPanel';
2
4
  export { FloatingPanel } from './FloatingPanel';
3
5
  export { FpsMeter } from './FpsMeter';
@@ -11,8 +13,6 @@ export type { ScaleIndicatorProps } from './ScaleIndicator';
11
13
  export { ScaleIndicator } from './ScaleIndicator';
12
14
  export type { SplitProps } from './Split';
13
15
  export { Split } from './Split';
14
- export type { StatusBarProps, StatusBarSectionProps } from './StatusBar';
15
- export { StatusBar } from './StatusBar';
16
16
  export type { ToolbarButtonProps, ToolbarGroupProps, ToolbarProps } from './Toolbar';
17
17
  export { Toolbar } from './Toolbar';
18
18
  export type { ZoomControlProps } from './ZoomControl';
@@ -72,7 +72,6 @@ import { JobProgress } from '../primitives/JobProgress';
72
72
  import { Legend, type LegendEntry } from '../primitives/Legend';
73
73
  import { ScaleIndicator } from '../primitives/ScaleIndicator';
74
74
  import { Split } from '../primitives/Split';
75
- import { StatusBar } from '../primitives/StatusBar';
76
75
  import { Toolbar } from '../primitives/Toolbar';
77
76
  import { ZoomControl } from '../primitives/ZoomControl';
78
77
 
@@ -831,12 +830,6 @@ function LabChrome() {
831
830
  </Toolbar.Group>
832
831
  </Toolbar>
833
832
  </Cell>
834
- <Cell label="StatusBar">
835
- <StatusBar>
836
- <StatusBar.Section>Items: 12</StatusBar.Section>
837
- <StatusBar.Section>Zoom: 100%</StatusBar.Section>
838
- </StatusBar>
839
- </Cell>
840
833
  <Cell label="Legend, JobProgress">
841
834
  <Legend entries={LEGEND} />
842
835
  <JobProgress job={JOB} />
@@ -1,8 +1,8 @@
1
+ import { createHistory } from '@weasel-js/core';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import {
3
4
  decodeUrlHash,
4
5
  deserializeTrials,
5
- emptyUndoStack,
6
6
  encodeUrlHash,
7
7
  labStorageKey,
8
8
  newId,
@@ -29,12 +29,6 @@ describe('encodeUrlHash / decodeUrlHash', () => {
29
29
  });
30
30
  });
31
31
 
32
- describe('emptyUndoStack', () => {
33
- it('returns an empty stack', () => {
34
- expect(emptyUndoStack()).toEqual({ past: [], future: [] });
35
- });
36
- });
37
-
38
32
  describe('serializeTrials', () => {
39
33
  it('returns records with the undo stack dropped', () => {
40
34
  const records = serializeTrials(
@@ -45,7 +39,7 @@ describe('serializeTrials', () => {
45
39
  config: { a: 1 },
46
40
  state: { b: 2 },
47
41
  view: { zoom: 1, pan: { x: 0, y: 0 } },
48
- undoStack: { past: [{ b: 1 }], future: [] },
42
+ history: createHistory(null),
49
43
  },
50
44
  ],
51
45
  {},
@@ -70,7 +64,6 @@ describe('serializeTrials', () => {
70
64
  config: {},
71
65
  state: { n: 2 },
72
66
  view: { zoom: 1, pan: { x: 0, y: 0 } },
73
- undoStack: { past: [], future: [] },
74
67
  },
75
68
  ],
76
69
  { Test: { serialize: (s) => ({ doubled: (s as { n: number }).n * 2 }) } },
@@ -80,7 +73,7 @@ describe('serializeTrials', () => {
80
73
  });
81
74
 
82
75
  describe('deserializeTrials', () => {
83
- it('rebuilds records with an empty undo stack', () => {
76
+ it('rebuilds records with no undo history', () => {
84
77
  const out = deserializeTrials(
85
78
  [
86
79
  {
@@ -93,7 +86,7 @@ describe('deserializeTrials', () => {
93
86
  ],
94
87
  {},
95
88
  );
96
- expect(out[0].undoStack).toEqual({ past: [], future: [] });
89
+ expect(out[0].history).toBeUndefined();
97
90
  });
98
91
 
99
92
  it('runs the instrument deserializer over the state', () => {
@@ -1,4 +1,4 @@
1
- import type { InstrumentSerializers, SerializedTrial, TrialRecord, UndoStack } from './types';
1
+ import type { InstrumentSerializers, SerializedTrial, TrialRecord } from './types';
2
2
 
3
3
  /** The storage key a lab writes one of its buckets under. Namespaced by
4
4
  * `storageKey` so two labs sharing an origin do not collide. */
@@ -41,11 +41,6 @@ export function newId(): string {
41
41
  return `lk-${Date.now().toString(36)}-${idCounter++}`;
42
42
  }
43
43
 
44
- /** A fresh, empty undo history. */
45
- export function emptyUndoStack(): UndoStack {
46
- return { past: [], future: [] };
47
- }
48
-
49
44
  /** Serialize trials for storage, running each instrument's own serializer
50
45
  * over its state. Undo history is deliberately dropped — it does not survive
51
46
  * a reload. */
@@ -53,7 +48,7 @@ export function serializeTrials(
53
48
  trials: TrialRecord[],
54
49
  serializers: InstrumentSerializers,
55
50
  ): SerializedTrial[] {
56
- return trials.map(({ undoStack: _undo, ...w }) => {
51
+ return trials.map(({ history: _history, ...w }) => {
57
52
  const s = serializers[w.instrumentName];
58
53
  return { ...w, state: s?.serialize ? s.serialize(w.state) : w.state };
59
54
  });
@@ -75,7 +70,6 @@ export function deserializeTrials(
75
70
  return {
76
71
  ...r,
77
72
  state: d?.deserialize ? d.deserialize(r.state, r.config) : r.state,
78
- undoStack: emptyUndoStack(),
79
73
  };
80
74
  });
81
75
  }
@@ -24,7 +24,6 @@ export {
24
24
  export {
25
25
  decodeUrlHash,
26
26
  deserializeTrials,
27
- emptyUndoStack,
28
27
  encodeUrlHash,
29
28
  labStorageKey,
30
29
  serializeTrials,
@@ -50,7 +49,6 @@ export type {
50
49
  StorageChange,
51
50
  TrialRecord,
52
51
  TrialStateHandle,
53
- UndoStack,
54
52
  } from './types';
55
53
  export type { UndockedPanel, UndockedPanels } from './undock';
56
54
  export { dockPanel, panelKey, undockPanel } from './undock';
@@ -1,3 +1,4 @@
1
+ import { createHistory } from '@weasel-js/core';
1
2
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
3
  import type { Instrument } from '../instrument/types';
3
4
  import { createMemoryAdapter } from './adapters';
@@ -61,13 +62,13 @@ describe('openLabStore — round trips', () => {
61
62
  const backing = new Map<string, unknown>();
62
63
  const first = await open(backing);
63
64
  first.store.getState().addTrial(trial('w1', { state: { n: 7 } }));
64
- first.store.getState().updateTrialUndoStack('w1', { past: [{ n: 6 }], future: [] });
65
+ first.store.getState().setTrialHistory('w1', createHistory(null));
65
66
  await first.close();
66
67
 
67
68
  const again = await open(backing);
68
69
  expect(again.store.getState().trials).toHaveLength(1);
69
70
  expect(again.store.getState().trials[0]).toMatchObject({ id: 'w1', state: { n: 7 } });
70
- expect(again.store.getState().trials[0]?.undoStack).toEqual({ past: [], future: [] });
71
+ expect(again.store.getState().trials[0]?.history).toBeUndefined();
71
72
  });
72
73
 
73
74
  // An instrument holding a Map, a Set or anything else storage cannot keep
@@ -321,7 +322,7 @@ describe('openLabStore — what a change writes', () => {
321
322
  storeLab(backing, { trials: [trial('a')] });
322
323
  const { storage, set } = counting(backing);
323
324
  const opened = await openLabStore({ storageKey: 'test', storage });
324
- opened.store.getState().updateTrialUndoStack('a', { past: [{}], future: [] });
325
+ opened.store.getState().setTrialHistory('a', createHistory(null));
325
326
  await opened.close();
326
327
  expect(set).not.toHaveBeenCalled();
327
328
  });
@@ -380,7 +381,7 @@ describe('openLabStore — trial order', () => {
380
381
  storeLab(backing, { trials: [trial('a'), trial('c')] });
381
382
  const opened = await open(backing);
382
383
  const [a, c] = opened.store.getState().trials as [TrialRecord, TrialRecord];
383
- const b: TrialRecord = { ...trial('b'), undoStack: { past: [], future: [] } };
384
+ const b: TrialRecord = trial('b');
384
385
  opened.store.setState({ trials: [a, b, c] });
385
386
  await opened.close();
386
387
  const ids = (await open(backing)).store.getState().trials.map((t) => t.id);
@@ -409,8 +410,9 @@ describe('openLabStore — two tabs', () => {
409
410
  storeLab(backing, { trials: [trial('t1', { state: { n: 0 } }), trial('t2')] });
410
411
  const tabA = await open(backing);
411
412
  const tabB = await open(backing);
412
- tabA.store.getState().updateTrialUndoStack('t1', { past: [{ n: -1 }], future: [] });
413
- tabA.store.getState().updateTrialUndoStack('t2', { past: [{ n: -2 }], future: [] });
413
+ const kept = createHistory(null);
414
+ tabA.store.getState().setTrialHistory('t1', createHistory(null));
415
+ tabA.store.getState().setTrialHistory('t2', kept);
414
416
 
415
417
  tabB.store.getState().updateTrialState('t1', { n: 5 });
416
418
  await tabB.records.flush();
@@ -418,8 +420,8 @@ describe('openLabStore — two tabs', () => {
418
420
 
419
421
  const [t1, t2] = tabA.store.getState().trials;
420
422
  expect(t1?.state).toEqual({ n: 5 });
421
- expect(t1?.undoStack).toEqual({ past: [], future: [] });
422
- expect(t2?.undoStack).toEqual({ past: [{ n: -2 }], future: [] });
423
+ expect(t1?.history).toBeUndefined();
424
+ expect(t2?.history).toBe(kept);
423
425
  });
424
426
 
425
427
  it("applies another tab's delete, snapshot, layout and mode without writing them back", async () => {
@@ -212,7 +212,7 @@ function persistedFieldsDiffer(a: TrialRecord, b: TrialRecord): boolean {
212
212
  const left = a as unknown as Record<string, unknown>;
213
213
  const right = b as unknown as Record<string, unknown>;
214
214
  for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
215
- if (key !== 'undoStack' && !Object.is(left[key], right[key])) return true;
215
+ if (key !== 'history' && !Object.is(left[key], right[key])) return true;
216
216
  }
217
217
  return false;
218
218
  }
@@ -29,7 +29,7 @@ describe('createLabStore — initial state', () => {
29
29
  });
30
30
 
31
31
  describe('addTrial', () => {
32
- it('adds a trial with an empty undoStack', () => {
32
+ it('adds a trial with no undo history', () => {
33
33
  const s = makeStore();
34
34
  s.getState().addTrial({
35
35
  id: 'w1',
@@ -39,7 +39,7 @@ describe('addTrial', () => {
39
39
  view: { zoom: 1, pan: { x: 0, y: 0 } },
40
40
  });
41
41
  expect(s.getState().trials).toHaveLength(1);
42
- expect(s.getState().trials[0]?.undoStack).toEqual({ past: [], future: [] });
42
+ expect(s.getState().trials[0]?.history).toBeUndefined();
43
43
  });
44
44
  });
45
45
 
@@ -1,3 +1,4 @@
1
+ import type { History } from '@weasel-js/core';
1
2
  import { createStore, type StoreApi } from 'zustand/vanilla';
2
3
  import { applyConfigWrite } from '../config/autoConfig';
3
4
  import { fillConfigDefaults } from '../config/path';
@@ -9,7 +10,7 @@ import {
9
10
  } from '../instrument/serializers';
10
11
  import type { InstrumentList } from '../instrument/types';
11
12
  import { emptyDocument } from './document';
12
- import { deserializeTrials, emptyUndoStack, newId } from './helpers';
13
+ import { deserializeTrials, newId } from './helpers';
13
14
  import type {
14
15
  CreateLabStoreOptions,
15
16
  InstrumentHooks,
@@ -29,7 +30,7 @@ import {
29
30
  /** Every mutation a lab store supports: managing trials, saving and
30
31
  * restoring snapshots, and setting the color mode. */
31
32
  export interface LabStoreActions {
32
- addTrial: (record: Omit<TrialRecord, 'undoStack'>) => void;
33
+ addTrial: (record: Omit<TrialRecord, 'history'>) => void;
33
34
  removeTrial: (id: string) => void;
34
35
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
35
36
  /** Write one config value. `path` is dotted for a value nested under an
@@ -42,10 +43,10 @@ export interface LabStoreActions {
42
43
  /** Fold or unfold one of a trial's sections. */
43
44
  setTrialSectionCollapsed: (id: string, key: string, collapsed: boolean) => void;
44
45
  updateTrialAnnotations: (id: string, doc: unknown) => void;
45
- updateTrialUndoStack: (
46
- id: string,
47
- next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
48
- ) => void;
46
+ /** Give a trial the undo history it made on its first snapshot. */
47
+ setTrialHistory: (id: string, history: History) => void;
48
+ /** Record where a trial's clock stands. */
49
+ updateTrialClock: (id: string, clock: { elapsed: number; rate: number }) => void;
49
50
  setTrialInstrument: (id: string, instrumentName: string) => void;
50
51
  saveSnapshot: (trialId: string, name: string) => void;
51
52
  loadSnapshot: (snapshotId: string, trialId: string) => void;
@@ -96,7 +97,7 @@ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
96
97
 
97
98
  addTrial: (record) => {
98
99
  set((s) => ({
99
- trials: [...s.trials, { ...record, undoStack: emptyUndoStack() }],
100
+ trials: [...s.trials, record],
100
101
  }));
101
102
  },
102
103
 
@@ -182,16 +183,14 @@ export function createLabStore(options: CreateLabStoreOptions = {}): LabStore {
182
183
  }));
183
184
  },
184
185
 
185
- updateTrialUndoStack: (id, next) => {
186
+ updateTrialClock: (id, clock) => {
186
187
  set((s) => ({
187
- trials: s.trials.map((w) => {
188
- if (w.id !== id) return w;
189
- const undoStack =
190
- typeof next === 'function'
191
- ? (next as (prev: TrialRecord['undoStack']) => TrialRecord['undoStack'])(w.undoStack)
192
- : next;
193
- return { ...w, undoStack };
194
- }),
188
+ trials: s.trials.map((w) => (w.id === id ? { ...w, clock } : w)),
189
+ }));
190
+ },
191
+ setTrialHistory: (id, history) => {
192
+ set((s) => ({
193
+ trials: s.trials.map((w) => (w.id === id ? { ...w, history } : w)),
195
194
  }));
196
195
  },
197
196
 
@@ -1,15 +1,9 @@
1
+ import type { History } from '@weasel-js/core';
1
2
  import type { ColorModePreference } from '@weasel-js/theme';
2
3
  import type { Auto } from '../config/auto';
3
4
  import type { ConfigPath, ResolvedConfig, ValueAtPath } from '../config/types';
4
5
  import type { InstrumentList } from '../instrument/types';
5
6
  import type { UndockedPanels } from './undock';
6
- /** A trial's undo history, as snapshots of its state either side of the
7
- * present. */
8
- export interface UndoStack {
9
- past: unknown[];
10
- future: unknown[];
11
- }
12
-
13
7
  /** The trial a per-trial call is coming from. `id` is the id `useTileId`
14
8
  * scopes a surface tile under, so a consumer keying its own per-trial
15
9
  * registry and labkit's tiles agree on the key. */
@@ -59,7 +53,13 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
59
53
  * no `annotations` — or declares its own `storage`. Not in `state`, which
60
54
  * belongs to the instrument and is typed as such. */
61
55
  annotations?: unknown;
62
- undoStack: UndoStack;
56
+ /** This trial's undo history over `state`, made by the trial on its first
57
+ * snapshot. Session-only: absent on a new, cloned, swapped or reloaded
58
+ * trial, which is how each starts with an empty history. */
59
+ history?: History;
60
+ /** Where the trial's clock stood at its last rate or seek change, for a
61
+ * trial whose instrument declares `clock`. */
62
+ clock?: { elapsed: number; rate: number };
63
63
  }
64
64
 
65
65
  /** A named, saved copy of a trial's config and state, restorable into any
@@ -190,7 +190,7 @@ export type InstrumentSerializers = Record<
190
190
 
191
191
  /** A trial as it is persisted: everything but the undo history, which is
192
192
  * session-only. */
193
- export type SerializedTrial = Omit<TrialRecord, 'undoStack'>;
193
+ export type SerializedTrial = Omit<TrialRecord, 'history'>;
194
194
 
195
195
  /** Everything a lab persists, under one key, at a known version. */
196
196
  export interface LabDocument {
package/src/styles.less CHANGED
@@ -8,12 +8,12 @@
8
8
  @import (reference, less) '@weasel-js/theme/inherited.module.css';
9
9
  @import './theme/base.less';
10
10
  @import './lab/Workspace.less';
11
+ @import './lightbox/Lightbox.less';
11
12
  @import './lab/LabShell.less';
12
13
  @import './lab/LabSwitcher.less';
13
14
  @import './primitives/Toolbar.less';
14
15
  @import './primitives/Split.less';
15
16
  @import './primitives/ZoomControl.less';
16
- @import './primitives/StatusBar.less';
17
17
  @import './primitives/JobProgress.less';
18
18
  @import './primitives/FpsMeter.less';
19
19
  @import './primitives/ScaleIndicator.less';
@@ -36,3 +36,4 @@
36
36
  @import './dragdrop/Palette.less';
37
37
  @import './dragdrop/DragGhost.less';
38
38
  @import './specimen/Specimen.less';
39
+ @import './lab/presentation.less';
@@ -58,6 +58,19 @@ was the only trial open.
58
58
  trial may register one, or none: a trial holding a drawn pane beside an undrawn
59
59
  one contributes a single rect, and a trial with nothing to draw contributes none.
60
60
 
61
+ ## An expanded tile
62
+
63
+ A `<Lightbox>` lifts its tile into the browser's top layer, which takes it out
64
+ from under the buffers. So the lab declares its two buffer layers with
65
+ `<LightboxLayers>` (`src/lab/LabSurface.tsx`), and an open lightbox lifts the
66
+ under layer, then itself, then the over layer — the same stacking as before,
67
+ each covering the window. The over layer is the surface container, so the
68
+ surface re-measures against the window and the buffers resize. The lightbox
69
+ also calls `scope(el)`: tiles outside it leave `rects`, are not painted, and
70
+ the retile clears them; `useTileInScope` tells a tenant's own chrome on the
71
+ surface (the annotation input box) to hide. A host owning its own surface does
72
+ the same for its buffers, or an expanded tile shows without them.
73
+
61
74
  ## Traps
62
75
 
63
76
  - **`preserveDrawingBuffer` is the consumer's job and is usually required.** A
@@ -9,6 +9,7 @@ export {
9
9
  useSurfaceOptional,
10
10
  useSurfaceTile,
11
11
  useTileId,
12
+ useTileInScope,
12
13
  } from './useSurfaceTile';
13
14
  export type {
14
15
  SurfaceClear,
@@ -9,11 +9,15 @@ function fakeHandle(): SurfaceHandle {
9
9
  invalidate: vi.fn(),
10
10
  invalidateAll: vi.fn(),
11
11
  invalidateRects: vi.fn(),
12
+ invalidateBox: vi.fn(),
12
13
  registerTile: vi.fn(),
13
14
  registerClear: vi.fn(() => () => {}),
14
15
  registerPainter: vi.fn(() => () => {}),
15
16
  containerRef: vi.fn(),
16
17
  getContainer: vi.fn(() => null),
18
+ scope: vi.fn(),
19
+ inScope: vi.fn(() => true),
20
+ subscribeScope: vi.fn(() => () => {}),
17
21
  };
18
22
  }
19
23
 
@@ -1,4 +1,4 @@
1
- import { useCallback, useContext } from 'react';
1
+ import { useCallback, useContext, useSyncExternalStore } from 'react';
2
2
  import { TrialIdContext } from '../state/context';
3
3
  import { SurfaceCanvasContext, SurfaceContext, type SurfaceLayer } from './SurfaceContext';
4
4
  import type { SurfaceHandle } from './useTiledSurface';
@@ -55,3 +55,19 @@ export function useSurfaceTile(id: string): (el: HTMLElement | null) => void {
55
55
  [surface, tileId],
56
56
  );
57
57
  }
58
+
59
+ const noSubscribe = (): (() => void) => () => {};
60
+
61
+ /**
62
+ * Whether the tile registered under `tileId` — the key `useTileId` returns —
63
+ * is inside the surface's scope. A tenant with chrome of its own on the
64
+ * surface hides it while this is false, since nothing paints that tile then.
65
+ * True with no surface above.
66
+ */
67
+ export function useTileInScope(tileId: string): boolean {
68
+ const surface = useSurfaceOptional();
69
+ return useSyncExternalStore(
70
+ surface?.subscribeScope ?? noSubscribe,
71
+ () => surface?.inScope(tileId) ?? true,
72
+ );
73
+ }