@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
@@ -109,6 +109,40 @@ describe('useTiledSurface', () => {
109
109
  expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
110
110
  });
111
111
 
112
+ it('narrows to the tiles inside a scope, retiling, and widens back', () => {
113
+ const frames: SurfaceFrame[] = [];
114
+ let handle: SurfaceHandle | null = null;
115
+ const h = (): SurfaceHandle | null => handle;
116
+ const { getByTestId } = render(
117
+ <Harness
118
+ frames={frames}
119
+ onHandle={(h) => {
120
+ handle = h;
121
+ }}
122
+ />,
123
+ );
124
+ flushFrames();
125
+ const heard = vi.fn();
126
+ act(() => {
127
+ h()?.subscribeScope(heard);
128
+ });
129
+ frames.length = 0;
130
+
131
+ act(() => h()?.scope(getByTestId('a')));
132
+ flushFrames();
133
+ expect(heard).toHaveBeenCalledTimes(1);
134
+ expect(h()?.inScope('a')).toBe(true);
135
+ expect(h()?.inScope('b')).toBe(false);
136
+ expect([...(frames[0]?.rects.keys() ?? [])]).toEqual(['a']);
137
+ expect(frames[0]?.retiled).toBe(true);
138
+
139
+ frames.length = 0;
140
+ act(() => h()?.scope(null));
141
+ flushFrames();
142
+ expect(h()?.inScope('b')).toBe(true);
143
+ expect([...(frames[0]?.rects.keys() ?? [])].sort()).toEqual(['a', 'b']);
144
+ });
145
+
112
146
  it('fires nothing on a clean tick', () => {
113
147
  const frames: SurfaceFrame[] = [];
114
148
  render(<Harness frames={frames} onHandle={() => {}} />);
@@ -207,6 +241,37 @@ describe('useTiledSurface', () => {
207
241
  expect(frames[0]?.rects.get('b')).toEqual({ x: 320, y: 0, w: 400, h: 600 });
208
242
  });
209
243
 
244
+ it('marks only the tiles a viewport box overlaps, measured from the container', () => {
245
+ const frames: SurfaceFrame[] = [];
246
+ let handle: SurfaceHandle | null = null;
247
+ const { getByTestId } = render(
248
+ <Harness
249
+ frames={frames}
250
+ onHandle={(h) => {
251
+ handle = h;
252
+ }}
253
+ />,
254
+ );
255
+ flushFrames();
256
+ frames.length = 0;
257
+
258
+ // The page scrolled: the container now sits at (100, 50). x 450 is 350 into it, inside a.
259
+ stubBox(getByTestId('stage'), 100, 50, 800, 600);
260
+ act(() => handle?.invalidateBox({ left: 450, top: 100, width: 40, height: 40 }));
261
+ flushFrames();
262
+ expect([...(frames[0]?.dirty ?? [])]).toEqual(['a']);
263
+
264
+ frames.length = 0;
265
+ act(() => handle?.invalidateBox({ left: 480, top: 100, width: 40, height: 40 }));
266
+ flushFrames();
267
+ expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
268
+
269
+ frames.length = 0;
270
+ act(() => handle?.invalidateBox({ left: 0, top: 0, width: 40, height: 40 }));
271
+ flushFrames();
272
+ expect(frames).toHaveLength(0);
273
+ });
274
+
210
275
  it('drops a tile that unregisters', () => {
211
276
  const frames: SurfaceFrame[] = [];
212
277
  let handle: SurfaceHandle | null = null;
@@ -27,6 +27,9 @@ export interface SurfaceHandle {
27
27
  invalidate: (id: string) => void;
28
28
  /** Mark every tile — what a resize or a tile-set change means. */
29
29
  invalidateAll: () => void;
30
+ /** Mark every tile overlapping `box`, in viewport px as `getBoundingClientRect`
31
+ * gives them: what something drawn over the tiles leaves behind when it moves. */
32
+ invalidateBox: (box: Box) => void;
30
33
  /** Re-measure before the next frame. The escape hatch for a host that knows it
31
34
  * moved something a ResizeObserver cannot see. */
32
35
  invalidateRects: () => void;
@@ -45,6 +48,15 @@ export interface SurfaceHandle {
45
48
  /** The element tile rects are measured against, and so the one a tile's own
46
49
  * chrome positions itself inside. Null before the owner attaches it. */
47
50
  getContainer: () => HTMLElement | null;
51
+ /** Narrow the surface to the tiles inside `el`, or widen it back with
52
+ * `null`. A tile outside drops out of the frame's `rects` and is not
53
+ * painted, and the frame is retiled so the buffer is cleared of it — what
54
+ * an expanded tile needs, with the buffers lifted over the rest. */
55
+ scope: (el: HTMLElement | null) => void;
56
+ /** Whether the tile is inside the current scope. Everything is, unscoped. */
57
+ inScope: (id: string) => boolean;
58
+ /** Called whenever the scope changes. Returns the unsubscribe. */
59
+ subscribeScope: (listener: () => void) => () => void;
48
60
  }
49
61
 
50
62
  /** What a tile's painter is handed: where it sits on the surface now, and the
@@ -78,6 +90,8 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
78
90
  const needsMeasure = useRef(true);
79
91
  const observer = useRef<ResizeObserver | null>(null);
80
92
  const lastDpr = useRef(0);
93
+ const scopeEl = useRef<HTMLElement | null>(null);
94
+ const scopeListeners = useRef(new Set<() => void>());
81
95
 
82
96
  // Held in a ref so a caller passing an inline closure does not re-create every
83
97
  // callback below on each render.
@@ -87,7 +101,10 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
87
101
  const el = container.current;
88
102
  if (!el) return false;
89
103
  const boxes = new Map<string, Box>();
90
- for (const [id, tile] of tiles.current) boxes.set(id, tile.getBoundingClientRect());
104
+ for (const [id, tile] of tiles.current) {
105
+ if (scopeEl.current && !scopeEl.current.contains(tile)) continue;
106
+ boxes.set(id, tile.getBoundingClientRect());
107
+ }
91
108
  const next = composeRects(el.getBoundingClientRect(), boxes);
92
109
  let changed = next.size !== rects.current.size;
93
110
  for (const [id, rect] of next) {
@@ -176,6 +193,25 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
176
193
  schedule();
177
194
  }, [schedule]);
178
195
 
196
+ const invalidateBox = useCallback(
197
+ (box: Box) => {
198
+ const el = container.current;
199
+ if (!el) return;
200
+ const at = el.getBoundingClientRect();
201
+ const x = box.left - at.left;
202
+ const y = box.top - at.top;
203
+ let hit = false;
204
+ for (const [id, r] of rects.current) {
205
+ if (x < r.x + r.w && r.x < x + box.width && y < r.y + r.h && r.y < y + box.height) {
206
+ dirty.current.add(id);
207
+ hit = true;
208
+ }
209
+ }
210
+ if (hit) schedule();
211
+ },
212
+ [schedule],
213
+ );
214
+
179
215
  const invalidateRects = useCallback(() => {
180
216
  needsMeasure.current = true;
181
217
  schedule();
@@ -216,6 +252,29 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
216
252
 
217
253
  const getContainer = useCallback(() => container.current, []);
218
254
 
255
+ const scope = useCallback(
256
+ (el: HTMLElement | null) => {
257
+ if (scopeEl.current === el) return;
258
+ scopeEl.current = el;
259
+ needsMeasure.current = true;
260
+ schedule();
261
+ for (const listener of scopeListeners.current) listener();
262
+ },
263
+ [schedule],
264
+ );
265
+
266
+ const inScope = useCallback((id: string) => {
267
+ const tile = tiles.current.get(id);
268
+ return !scopeEl.current || (tile !== undefined && scopeEl.current.contains(tile));
269
+ }, []);
270
+
271
+ const subscribeScope = useCallback((listener: () => void) => {
272
+ scopeListeners.current.add(listener);
273
+ return () => {
274
+ scopeListeners.current.delete(listener);
275
+ };
276
+ }, []);
277
+
219
278
  const containerRef = useCallback(
220
279
  (el: HTMLElement | null) => {
221
280
  if (container.current === el) return;
@@ -247,22 +306,30 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
247
306
  () => ({
248
307
  invalidate,
249
308
  invalidateAll,
309
+ invalidateBox,
250
310
  invalidateRects,
251
311
  registerTile,
252
312
  registerClear,
253
313
  registerPainter,
254
314
  containerRef,
255
315
  getContainer,
316
+ scope,
317
+ inScope,
318
+ subscribeScope,
256
319
  }),
257
320
  [
258
321
  invalidate,
259
322
  invalidateAll,
323
+ invalidateBox,
260
324
  invalidateRects,
261
325
  registerTile,
262
326
  registerClear,
263
327
  registerPainter,
264
328
  containerRef,
265
329
  getContainer,
330
+ scope,
331
+ inScope,
332
+ subscribeScope,
266
333
  ],
267
334
  );
268
335
  }
@@ -64,20 +64,6 @@
64
64
  "type": "color"
65
65
  }
66
66
  },
67
- "fg-muted": {
68
- "by": "mode",
69
- "dark": {
70
- "value": "#a8a8c0",
71
- "type": "color"
72
- }
73
- },
74
- "fg-subtle": {
75
- "by": "mode",
76
- "dark": {
77
- "value": "#5a5a78",
78
- "type": "color"
79
- }
80
- },
81
67
  "accent": {
82
68
  "by": "mode",
83
69
  "dark": {
@@ -23,7 +23,6 @@ const record: TrialRecord = {
23
23
  config: {},
24
24
  state: {},
25
25
  view: { zoom: 1, pan: { x: 0, y: 0 } },
26
- undoStack: { past: [], future: [] },
27
26
  };
28
27
 
29
28
  function Harness() {
@@ -27,7 +27,6 @@ const stubRecord: TrialRecord = {
27
27
  config: {},
28
28
  state: {},
29
29
  view: { zoom: 1, pan: { x: 0, y: 0 } },
30
- undoStack: { past: [], future: [] },
31
30
  };
32
31
 
33
32
  type ChromeProps = Parameters<typeof TrialChrome>[0];
@@ -37,8 +36,7 @@ type ChromeProps = Parameters<typeof TrialChrome>[0];
37
36
  * keeps its own store and hands it to both. */
38
37
  function makeChromeStore() {
39
38
  const store = createLabStore();
40
- const { undoStack: _undo, ...seed } = stubRecord;
41
- store.getState().addTrial(seed);
39
+ store.getState().addTrial(stubRecord);
42
40
  return store;
43
41
  }
44
42
 
@@ -357,3 +355,42 @@ describe('<TrialChrome> — state that outlives a mount', () => {
357
355
  expect(screen.getByTestId('notes-body')).toBeInTheDocument();
358
356
  });
359
357
  });
358
+
359
+ describe('state undo in a mounted lab', () => {
360
+ const counter: Instrument<{ n: number }> = {
361
+ name: 'Counter',
362
+ defaultConfig: () => ({}),
363
+ initialState: () => ({ n: 0 }),
364
+ undo: { snapshotOn: ['bump'] },
365
+ render: (ctx) => (
366
+ <button
367
+ type="button"
368
+ onClick={() => {
369
+ ctx.emit('bump');
370
+ ctx.setState((s) => ({ n: s.n + 1 }));
371
+ }}
372
+ >
373
+ {`n=${ctx.state.n}`}
374
+ </button>
375
+ ),
376
+ };
377
+
378
+ it('undoes and redoes a change through the trial history', async () => {
379
+ await renderSettled(<Lab title="T" instruments={[counter]} defaultInstrument="Counter" />);
380
+ const undo = screen.getByRole('button', { name: 'Undo' });
381
+ const redo = screen.getByRole('button', { name: 'Redo' });
382
+ expect(undo).toBeDisabled();
383
+ fireEvent.click(screen.getByRole('button', { name: 'n=0' }));
384
+ fireEvent.click(screen.getByRole('button', { name: 'n=1' }));
385
+ expect(undo).not.toBeDisabled();
386
+ fireEvent.click(undo);
387
+ expect(screen.getByRole('button', { name: 'n=1' })).toBeInTheDocument();
388
+ fireEvent.click(undo);
389
+ expect(screen.getByRole('button', { name: 'n=0' })).toBeInTheDocument();
390
+ expect(undo).toBeDisabled();
391
+ fireEvent.click(redo);
392
+ fireEvent.click(redo);
393
+ expect(screen.getByRole('button', { name: 'n=2' })).toBeInTheDocument();
394
+ expect(redo).toBeDisabled();
395
+ });
396
+ });
@@ -1,5 +1,7 @@
1
1
  import {
2
+ createHistory,
2
3
  createPointerStore,
4
+ type History,
3
5
  PointerContextProvider,
4
6
  useLatest,
5
7
  WeaselProvider,
@@ -13,6 +15,7 @@ import {
13
15
  useMemo,
14
16
  useRef,
15
17
  useState,
18
+ useSyncExternalStore,
16
19
  } from 'react';
17
20
  import { useStore } from 'zustand/react';
18
21
  import { AnnotationsContext } from '../annotations/AnnotationsContext';
@@ -28,6 +31,8 @@ import { fitStage, Stage } from '../canvas/Stage';
28
31
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
29
32
  import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
30
33
  import type { TrialContribution } from '../chrome/types';
34
+ import { ClockRegistryContext, TrialClockContext } from '../clock/clockRegistry';
35
+ import { createTrialClock } from '../clock/trialClock';
31
36
  import { useConfigSchema } from '../config/useConfigSchema';
32
37
  import { useResolvedConfig } from '../config/useResolvedConfig';
33
38
  import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
@@ -48,7 +53,7 @@ import type { TrialRecord } from '../state/types';
48
53
  import { as2DView, DEFAULT_VIEW, withZoom } from '../state/view';
49
54
  import { resolveLabTool } from '../tools/labTool';
50
55
  import { createEventBus, type EventBus } from '../undo/eventBus';
51
- import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
56
+ import { stateOp } from '../undo/stateOp';
52
57
  import { LoupeSwitchContext } from './loupeSwitch';
53
58
  import type { LoupeBindings, UndoBindings } from './TrialChrome';
54
59
  import { TrialChrome } from './TrialChrome';
@@ -161,7 +166,8 @@ function TrialRuntime({
161
166
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
162
167
  const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
163
168
  const updateTrialView = useStore(store, (s) => s.updateTrialView);
164
- const updateTrialUndoStack = useStore(store, (s) => s.updateTrialUndoStack);
169
+ const setTrialHistory = useStore(store, (s) => s.setTrialHistory);
170
+ const updateTrialClock = useStore(store, (s) => s.updateTrialClock);
165
171
  const updateTrialAnnotations = useStore(store, (s) => s.updateTrialAnnotations);
166
172
  const labToolId = useStore(store, (s) => s.activeToolId);
167
173
  const setLabTool = useStore(store, (s) => s.setLabTool);
@@ -194,6 +200,28 @@ function TrialRuntime({
194
200
  [instrument.canvas, layerVisibility],
195
201
  );
196
202
 
203
+ // Read once: `record.clock` is where a reopened trial's clock stood, and the
204
+ // clock is the truth from then on. A swap mounts a new trial under a new id.
205
+ const [clockHandle] = useState(() =>
206
+ instrument.clock ? createTrialClock(instrument.clock, record.clock) : null,
207
+ );
208
+ const clocks = useContext(ClockRegistryContext);
209
+ useEffect(() => {
210
+ if (!clockHandle || !clocks) return;
211
+ return clocks.register(record.id, clockHandle);
212
+ }, [clocks, clockHandle, record.id]);
213
+ useEffect(() => {
214
+ if (!clockHandle) return;
215
+ const { clock } = clockHandle;
216
+ return clock.subscribe(() =>
217
+ updateTrialClock(record.id, { elapsed: clock.elapsed, rate: clock.rate }),
218
+ );
219
+ }, [clockHandle, record.id, updateTrialClock]);
220
+ const ticks = useMemo(
221
+ () => (clockHandle ? (fn: () => void) => clockHandle.onFrame(() => fn()) : undefined),
222
+ [clockHandle],
223
+ );
224
+
197
225
  const undoCap = instrument.undo;
198
226
  const undoEvents = useMemo(() => new Set(undoCap?.snapshotOn ?? ['state.change']), [undoCap]);
199
227
  const maxDepth = undoCap?.maxDepth ?? 50;
@@ -202,8 +230,14 @@ function TrialRuntime({
202
230
  if (!undoCap || !undoEvents.has(event)) return;
203
231
  const current = store.getState().trials.find((w) => w.id === record.id);
204
232
  if (!current) return;
205
- const snap = structuredClone(current.state);
206
- updateTrialUndoStack(record.id, (prev) => pushSnapshot(prev, snap, maxDepth));
233
+ let history = current.history;
234
+ if (!history) {
235
+ history = createHistory(null, { historyLimit: maxDepth });
236
+ setTrialHistory(record.id, history);
237
+ }
238
+ const read = () => store.getState().trials.find((w) => w.id === record.id)?.state;
239
+ const write = (state: unknown) => updateTrialState(record.id, state as never);
240
+ history.recordEntry([stateOp(structuredClone(current.state), read, write)], event);
207
241
  };
208
242
 
209
243
  const setView = (v: unknown): void => updateTrialView(record.id, v);
@@ -383,6 +417,7 @@ function TrialRuntime({
383
417
  activeToolId: resolvedToolId,
384
418
  visibleLayers,
385
419
  pointer,
420
+ ...(clockHandle ? { clock: clockHandle.clock } : {}),
386
421
  },
387
422
  emit: (event) => {
388
423
  snapshotIfNeeded(event);
@@ -395,31 +430,20 @@ function TrialRuntime({
395
430
  // stack — the spec's rule, and the reason a mark scene is the truth. A trial
396
431
  // declaring both takes the marks first: the most recent thing the user did
397
432
  // is what undo is for, and only a mark change moves the mark history.
398
- const undoState = () => {
399
- const result = undoUndo(record.undoStack, structuredClone(record.state));
400
- if (!result) return;
401
- updateTrialState(record.id, result.snapshot as never);
402
- updateTrialUndoStack(record.id, result.stack);
403
- };
404
- const redoState = () => {
405
- const result = undoRedo(record.undoStack, structuredClone(record.state));
406
- if (!result) return;
407
- updateTrialState(record.id, result.snapshot as never);
408
- updateTrialUndoStack(record.id, result.stack);
409
- };
433
+ const stateHistory = useHistoryVersion(record.history);
410
434
 
411
435
  const undoBindings: UndoBindings | undefined =
412
436
  undoCap || annotationsCap
413
437
  ? {
414
- canUndo: markMoves.undo || (undoCap ? record.undoStack.past.length > 0 : false),
415
- canRedo: markMoves.redo || (undoCap ? record.undoStack.future.length > 0 : false),
438
+ canUndo: markMoves.undo || (undoCap ? (stateHistory?.canUndo() ?? false) : false),
439
+ canRedo: markMoves.redo || (undoCap ? (stateHistory?.canRedo() ?? false) : false),
416
440
  undo: () => {
417
441
  if (annotationsCap && annotations.undo()) return;
418
- if (undoCap) undoState();
442
+ if (undoCap) stateHistory?.undo();
419
443
  },
420
444
  redo: () => {
421
445
  if (annotationsCap && annotations.redo()) return;
422
- if (undoCap) redoState();
446
+ if (undoCap) stateHistory?.redo();
423
447
  },
424
448
  }
425
449
  : undefined;
@@ -433,17 +457,26 @@ function TrialRuntime({
433
457
  const ordered = layerOrder
434
458
  ? [...baseLayers].sort((a, b) => layerOrder.indexOf(a.id) - layerOrder.indexOf(b.id))
435
459
  : baseLayers;
460
+ const clock = clockHandle?.clock;
436
461
  return ordered.map((layer) => ({
437
462
  id: layer.id,
438
463
  visible: layerVisibility[layer.id] !== false,
464
+ timed: layer.timed === true,
439
465
  render: (ctx, view, frame) => {
440
466
  // Camera applied here, so a layer draws in world coordinates. `zoom`
441
467
  // stays in the args for line widths, which must not scale with it.
442
468
  applyCamera(ctx, view, frame);
443
- layer.draw(ctx, { state: record.state, config, zoom: view.zoom });
469
+ layer.draw(ctx, {
470
+ state: record.state,
471
+ config,
472
+ zoom: view.zoom,
473
+ elapsed: clock?.elapsed ?? 0,
474
+ pass: clock?.pass ?? 0,
475
+ phase: clock?.phase ?? 0,
476
+ });
444
477
  },
445
478
  }));
446
- }, [instrument.canvas, record.state, config, layerVisibility, layerOrder]);
479
+ }, [instrument.canvas, record.state, config, layerVisibility, layerOrder, clockHandle]);
447
480
 
448
481
  const layerDescriptors: LayerDescriptor[] = useMemo(() => {
449
482
  if (!instrument.layers) return [];
@@ -528,6 +561,7 @@ function TrialRuntime({
528
561
  onResize={placeView}
529
562
  minZoom={instrument.canvas.minZoom}
530
563
  maxZoom={instrument.canvas.maxZoom}
564
+ ticks={ticks}
531
565
  >
532
566
  {instrument.render(renderCtx)}
533
567
  </CanvasStack>
@@ -625,27 +659,37 @@ function TrialRuntime({
625
659
 
626
660
  return (
627
661
  <TrialIdProvider trialId={record.id}>
628
- <AnnotationsContext.Provider value={annotationsCap ? provided : null}>
629
- <CameraWheelContext.Provider value={wheelSlot}>
630
- <TrialChrome
631
- job={jobCap ? job : undefined}
632
- loupe={loupeBindings}
633
- trialId={record.id}
634
- record={record}
635
- instrument={instrument}
636
- isLastTrial={isLast}
637
- undoBindings={undoBindings}
638
- trialChrome={extraChrome}
639
- chrome={chrome}
640
- suppress={suppress}
641
- activeToolId={resolvedToolId}
642
- setActiveTool={setActiveTool}
643
- >
644
- {scopedBody}
645
- {annotationOverlays}
646
- </TrialChrome>
647
- </CameraWheelContext.Provider>
648
- </AnnotationsContext.Provider>
662
+ <TrialClockContext.Provider value={clockHandle}>
663
+ <AnnotationsContext.Provider value={annotationsCap ? provided : null}>
664
+ <CameraWheelContext.Provider value={wheelSlot}>
665
+ <TrialChrome
666
+ job={jobCap ? job : undefined}
667
+ loupe={loupeBindings}
668
+ trialId={record.id}
669
+ record={record}
670
+ instrument={instrument}
671
+ isLastTrial={isLast}
672
+ undoBindings={undoBindings}
673
+ trialChrome={extraChrome}
674
+ chrome={chrome}
675
+ suppress={suppress}
676
+ activeToolId={resolvedToolId}
677
+ setActiveTool={setActiveTool}
678
+ >
679
+ {scopedBody}
680
+ {annotationOverlays}
681
+ </TrialChrome>
682
+ </CameraWheelContext.Provider>
683
+ </AnnotationsContext.Provider>
684
+ </TrialClockContext.Provider>
649
685
  </TrialIdProvider>
650
686
  );
651
687
  }
688
+
689
+ const NO_SUBSCRIBE = () => () => {};
690
+
691
+ /** `history`, re-rendering the caller whenever it changes. */
692
+ function useHistoryVersion(history: History | undefined): History | undefined {
693
+ useSyncExternalStore(history?.subscribe ?? NO_SUBSCRIBE, () => history?.getVersion() ?? 0);
694
+ return history;
695
+ }
@@ -24,6 +24,8 @@ import { useResolvedConfig } from '../config/useResolvedConfig';
24
24
  import type { Instrument } from '../instrument/types';
25
25
  import type { JobHandle } from '../job/types';
26
26
  import { useLabContext } from '../lab/LabContext';
27
+ import { usePresentedTrial } from '../lab/presentation';
28
+ import { useLightboxControl } from '../lightbox/LightboxContext';
27
29
  import { JobProgress } from '../primitives/JobProgress';
28
30
  import { LabStoreContext } from '../state/context';
29
31
  import type { TrialRecord } from '../state/types';
@@ -98,6 +100,7 @@ export function TrialChrome({
98
100
  children,
99
101
  }: TrialChromeProps) {
100
102
  const lab = useLabContext();
103
+ const { presented, ref: presentedRef } = usePresentedTrial(trialId);
101
104
  const storeCtx = useContext(LabStoreContext);
102
105
  if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
103
106
  const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
@@ -141,6 +144,9 @@ export function TrialChrome({
141
144
  () => cameras?.get(trialId) ?? null,
142
145
  );
143
146
 
147
+ // The title bar carries the expand toggle, so the lightbox drops its own.
148
+ const lightbox = useLightboxControl();
149
+
144
150
  const ctx = useMemo<TrialChromeContext>(() => {
145
151
  const setZoom = (z: number): void => {
146
152
  if (camera) zoomCameraTo(camera, z);
@@ -161,6 +167,9 @@ export function TrialChrome({
161
167
  hasLoupe: loupe != null,
162
168
  loupeOn: loupe?.on ?? false,
163
169
  toggleLoupe: loupe?.toggle ?? NO_OP,
170
+ canExpand: lightbox != null,
171
+ expanded: lightbox?.expanded ?? false,
172
+ toggleExpanded: lightbox?.toggle ?? NO_OP,
164
173
  configSchema,
165
174
  configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
166
175
  config: record.config,
@@ -210,6 +219,7 @@ export function TrialChrome({
210
219
  updateTrialState,
211
220
  undoBindings,
212
221
  loupe,
222
+ lightbox,
213
223
  view2d,
214
224
  activeToolId,
215
225
  setActiveTool,
@@ -245,7 +255,8 @@ export function TrialChrome({
245
255
 
246
256
  const handleKeyDown = (e: KeyboardEvent<HTMLElement>): void => {
247
257
  const mod = e.metaKey || e.ctrlKey;
248
- if (!mod) return;
258
+ // A presented trial has no chrome, so nothing the chrome's keys act on.
259
+ if (!mod || presented) return;
249
260
  if (e.key === 'z' || e.key === 'Z') {
250
261
  e.preventDefault();
251
262
  if (e.shiftKey) ctx.redo();
@@ -258,9 +269,11 @@ export function TrialChrome({
258
269
 
259
270
  return (
260
271
  <section
272
+ ref={presentedRef}
261
273
  className="lk-trial"
262
274
  data-trial-id={trialId}
263
275
  data-focused={lab.trials.length > 1 && lab.focusedTrialId === trialId ? 'true' : undefined}
276
+ data-lk-presented={presented ? '' : undefined}
264
277
  aria-label={`Trial ${title}`}
265
278
  tabIndex={-1}
266
279
  onKeyDown={handleKeyDown}
@@ -280,7 +293,12 @@ export function TrialChrome({
280
293
  width={record.sidebarWidth}
281
294
  onWidthChange={(w) => updateTrialSidebarWidth(trialId, w)}
282
295
  contentClassName={
283
- instrument.canvas || instrument.stage ? 'lk-trial__content--flush' : undefined
296
+ [
297
+ instrument.canvas || instrument.stage ? 'lk-trial__content--flush' : '',
298
+ presented ? 'lk-trial__stage' : '',
299
+ ]
300
+ .filter(Boolean)
301
+ .join(' ') || undefined
284
302
  }
285
303
  sidebar={
286
304
  <>
@@ -258,7 +258,6 @@ describe('reorderTrials', () => {
258
258
  config: {},
259
259
  state: {},
260
260
  view: { zoom: 1, pan: { x: 0, y: 0 } },
261
- undoStack: { past: [], future: [] },
262
261
  }) as never;
263
262
 
264
263
  it('reorders to match the given ids', () => {
@@ -322,7 +321,7 @@ describe('swapTrial', () => {
322
321
  expect(swapped.config).toEqual({ subject: 'moon', zoom: 1 });
323
322
  expect(swapped.state).toEqual({ label: 'MOON' });
324
323
  expect(swapped.configSeed).toEqual({ subject: 'moon' });
325
- expect(swapped.undoStack).toEqual({ past: [], future: [] });
324
+ expect(swapped.history).toBeUndefined();
326
325
  });
327
326
 
328
327
  it('keeps the width the trial’s sidebar was dragged to', () => {
@@ -89,7 +89,6 @@ export function addTrial(
89
89
  config,
90
90
  state,
91
91
  view: initialView(instrument),
92
- undoStack: { past: [], future: [] },
93
92
  };
94
93
  if (options.config) record.configSeed = seeded.config;
95
94
  if (seeded.autoPaths.length > 0) record.autoSeed = seeded.autoPaths;
@@ -103,13 +102,13 @@ export function cloneTrial(trials: TrialRecord[], id: string): TrialRecord[] {
103
102
  const sourceIdx = trials.findIndex((w) => w.id === id);
104
103
  const source = trials[sourceIdx];
105
104
  if (!source) return trials;
105
+ const { history: _history, ...rest } = source;
106
106
  const clone: TrialRecord = {
107
- ...source,
107
+ ...rest,
108
108
  id: newId(),
109
109
  config: structuredClone(source.config),
110
110
  state: structuredClone(source.state),
111
111
  view: structuredClone(source.view),
112
- undoStack: { past: [], future: [] },
113
112
  };
114
113
  return [...trials.slice(0, sourceIdx + 1), clone, ...trials.slice(sourceIdx + 1)];
115
114
  }
@@ -0,0 +1,21 @@
1
+ import type { Op } from '@weasel-js/core';
2
+
3
+ /**
4
+ * An undo entry over a trial's whole `state`: it holds the state as it stood
5
+ * `before`, and catches the state to redo to each time it is undone, since a
6
+ * snapshot is taken when an undoable event fires, before the change it
7
+ * announces has necessarily landed. Never serialized: trial undo is
8
+ * session-only.
9
+ */
10
+ export function stateOp(before: unknown, read: () => unknown, write: (state: unknown) => void): Op {
11
+ let after: unknown;
12
+ const forward: Op = {
13
+ label: 'state',
14
+ apply: () => write(structuredClone(after)),
15
+ invert: () => {
16
+ after = structuredClone(read());
17
+ return { label: 'state', apply: () => write(structuredClone(before)), invert: () => forward };
18
+ },
19
+ };
20
+ return forward;
21
+ }