@weasel-js/labkit 1.4.4 → 1.5.0

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 (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +110 -381
  53. package/dist/index.js +691 -817
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +164 -122
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +84 -50
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -286,4 +286,179 @@ describe('useTiledSurface', () => {
286
286
  flushFrames();
287
287
  expect(paint).toHaveBeenCalledTimes(1);
288
288
  });
289
+
290
+ /**
291
+ * The surface owner resizes the shared buffer from inside `onFrame`, which
292
+ * costs every tile its pixels. Both tests below are about the two ways that
293
+ * loss goes unrepaired.
294
+ */
295
+ describe('when the buffer loses its contents', () => {
296
+ it('keeps an invalidation the owner makes from inside onFrame', () => {
297
+ let handle: SurfaceHandle | null = null;
298
+ let claimAll = false;
299
+ function Owner() {
300
+ const surface = useTiledSurface({
301
+ onFrame: () => {
302
+ if (!claimAll) return;
303
+ claimAll = false;
304
+ // What `Lab` does after sizing the canvas: the buffer is blank, so
305
+ // every tile owes a repaint, not only the one that moved.
306
+ surface.invalidateAll();
307
+ },
308
+ });
309
+ handle = surface;
310
+ return (
311
+ <div
312
+ ref={(el) => {
313
+ if (!el) return;
314
+ stubBox(el, 0, 0, 800, 600);
315
+ surface.containerRef(el);
316
+ }}
317
+ >
318
+ <div ref={(el) => { if (!el) return; stubBox(el, 0, 0, 400, 600); surface.registerTile('a', el); }} />
319
+ <div ref={(el) => { if (!el) return; stubBox(el, 400, 0, 400, 600); surface.registerTile('b', el); }} />
320
+ </div>
321
+ );
322
+ }
323
+ render(<Owner />);
324
+ flushFrames();
325
+
326
+ const paintA = vi.fn();
327
+ act(() => {
328
+ handle?.registerPainter('a', paintA);
329
+ handle?.registerPainter('b', vi.fn());
330
+ });
331
+ flushFrames();
332
+ paintA.mockClear();
333
+
334
+ // Only b is dirty; the owner resizes the buffer during the frame, which
335
+ // blanks a as well.
336
+ claimAll = true;
337
+ act(() => handle?.invalidate('b'));
338
+ flushFrames();
339
+ flushFrames();
340
+
341
+ expect(paintA).toHaveBeenCalled();
342
+ });
343
+
344
+ it('tells the owner when the tile geometry changed, so it can clear', () => {
345
+ const frames: SurfaceFrame[] = [];
346
+ let handle: SurfaceHandle | null = null;
347
+ const { getByTestId } = render(
348
+ <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
349
+ );
350
+ flushFrames();
351
+ frames.length = 0;
352
+
353
+ // A plain repaint leaves every tile where it was.
354
+ act(() => handle?.invalidate('a'));
355
+ flushFrames();
356
+ expect(frames[0]?.retiled).toBe(false);
357
+ frames.length = 0;
358
+
359
+ // A tile moving strands its old pixels in the gutter; nothing but the
360
+ // owner can clear those, and this is how it hears about it.
361
+ stubBox(getByTestId('b'), 300, 0, 400, 600);
362
+ act(() => handle?.invalidateRects());
363
+ flushFrames();
364
+ expect(frames[0]?.retiled).toBe(true);
365
+ });
366
+
367
+ it('runs every registered clear before any painter, and only on a re-tile', () => {
368
+ const frames: SurfaceFrame[] = [];
369
+ let handle: SurfaceHandle | null = null;
370
+ const { getByTestId } = render(
371
+ <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
372
+ );
373
+ flushFrames();
374
+
375
+ const order: string[] = [];
376
+ const clearA = vi.fn(() => order.push('clearA'));
377
+ const clearB = vi.fn(() => order.push('clearB'));
378
+ act(() => {
379
+ handle?.registerClear('a', clearA);
380
+ handle?.registerClear('b', clearB);
381
+ handle?.registerPainter('a', () => order.push('paintA'));
382
+ handle?.registerPainter('b', () => order.push('paintB'));
383
+ });
384
+ flushFrames();
385
+ order.length = 0;
386
+ clearA.mockClear();
387
+
388
+ // A repaint of one tile leaves every other tile's pixels valid.
389
+ act(() => handle?.invalidate('a'));
390
+ flushFrames();
391
+ expect(clearA).not.toHaveBeenCalled();
392
+ expect(order).toEqual(['paintA']);
393
+ order.length = 0;
394
+
395
+ stubBox(getByTestId('b'), 300, 0, 400, 600);
396
+ act(() => handle?.invalidateRects());
397
+ flushFrames();
398
+
399
+ // Both tenants share the buffer, so both clears run — and a clear after
400
+ // a painter would erase the tile that just drew.
401
+ expect(order.slice(0, 2).sort()).toEqual(['clearA', 'clearB']);
402
+ expect(order.slice(2).sort()).toEqual(['paintA', 'paintB']);
403
+ expect(clearA).toHaveBeenCalledWith({ width: 800, height: 600 }, expect.any(Number));
404
+ });
405
+
406
+ it('keeps measuring while a tile is still moving, and stops when it settles', () => {
407
+ const frames: SurfaceFrame[] = [];
408
+ let handle: SurfaceHandle | null = null;
409
+ const { getByTestId } = render(
410
+ <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
411
+ );
412
+ flushFrames();
413
+
414
+ const seen: number[] = [];
415
+ act(() => {
416
+ handle?.registerPainter('b', (rect) => seen.push(rect.x));
417
+ });
418
+
419
+ // A panel whose size is already final but whose position is still
420
+ // animating: `ResizeObserver` has nothing left to report, so the only
421
+ // thing that can catch the last 60px is the surface measuring again.
422
+ const b = getByTestId('b');
423
+ const stops = [340, 370, 400, 400, 400];
424
+ let call = 0;
425
+ vi.spyOn(b, 'getBoundingClientRect').mockImplementation(() => {
426
+ const left = stops[Math.min(call++, stops.length - 1)]!;
427
+ return { left, top: 0, width: 400, height: 600, right: left + 400, bottom: 600, x: left, y: 0, toJSON: () => ({}) } as DOMRect;
428
+ });
429
+
430
+ seen.length = 0;
431
+ act(() => handle?.invalidateRects());
432
+ // One flush per animation frame; nothing else invalidates in between.
433
+ flushFrames();
434
+ flushFrames();
435
+ flushFrames();
436
+ flushFrames();
437
+
438
+ expect(seen.at(-1)).toBe(400);
439
+ });
440
+
441
+ it('stops calling a clear that unregisters', () => {
442
+ const frames: SurfaceFrame[] = [];
443
+ let handle: SurfaceHandle | null = null;
444
+ const { getByTestId } = render(
445
+ <Harness frames={frames} onHandle={(h) => { handle = h; }} />,
446
+ );
447
+ flushFrames();
448
+
449
+ const clear = vi.fn();
450
+ let off: (() => void) | undefined;
451
+ act(() => { off = handle?.registerClear('a', clear); });
452
+ stubBox(getByTestId('b'), 310, 0, 400, 600);
453
+ act(() => handle?.invalidateRects());
454
+ flushFrames();
455
+ expect(clear).toHaveBeenCalledTimes(1);
456
+
457
+ act(() => off?.());
458
+ stubBox(getByTestId('b'), 320, 0, 400, 600);
459
+ act(() => handle?.invalidateRects());
460
+ flushFrames();
461
+ expect(clear).toHaveBeenCalledTimes(1);
462
+ });
463
+ });
289
464
  });
@@ -14,6 +14,11 @@ export interface SurfaceFrame {
14
14
  rects: ReadonlyMap<string, Rect>;
15
15
  dpr: number;
16
16
  size: { width: number; height: number };
17
+ /** The tile geometry changed this frame — a tile moved, resized, appeared or
18
+ * went away, or the dpr did. Every tile is dirty on such a frame, but the
19
+ * pixels a tile vacated are in no tile's scissor now, so the owner clears
20
+ * the whole buffer. `<Lab>` does; a host owning its own surface must too. */
21
+ retiled: boolean;
17
22
  }
18
23
 
19
24
  /** The invalidators and the two ref callbacks that publish geometry. */
@@ -26,6 +31,12 @@ export interface SurfaceHandle {
26
31
  * moved something a ResizeObserver cannot see. */
27
32
  invalidateRects: () => void;
28
33
  registerTile: (id: string, el: HTMLElement | null) => void;
34
+ /** Register a tenant's whole-buffer clear. Every one registered runs, before
35
+ * any painter, on a frame where the tile geometry changed — the pixels a
36
+ * tile vacated are in no scissor now, and only the context that drew them
37
+ * can erase them. Backend-agnostic by necessity: labkit owns the canvas,
38
+ * never the context. */
39
+ registerClear: (id: string, clear: SurfaceClear) => () => void;
29
40
  /** Subscribe a tile to the frames it is dirty on. A tile paints on its own
30
41
  * loop, so this is how the surface wakes one: a resize of the shared buffer
31
42
  * clears every tile, not only the one that moved. */
@@ -40,6 +51,10 @@ export interface SurfaceHandle {
40
51
  * frame that dirtied it. */
41
52
  export type TilePainter = (rect: Rect, frame: SurfaceFrame) => void;
42
53
 
54
+ /** Erase the whole shared buffer. Device pixels, because that is what a GL
55
+ * viewport takes. */
56
+ export type SurfaceClear = (size: { width: number; height: number }, dpr: number) => void;
57
+
43
58
  export interface UseTiledSurfaceOptions {
44
59
  onFrame: (frame: SurfaceFrame) => void;
45
60
  }
@@ -50,6 +65,7 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
50
65
  const rects = useRef(new Map<string, Rect>());
51
66
  const dirty = useRef(new Set<string>());
52
67
  const painters = useRef(new Map<string, TilePainter>());
68
+ const clears = useRef(new Map<string, SurfaceClear>());
53
69
  const needsMeasure = useRef(true);
54
70
  const observer = useRef<ResizeObserver | null>(null);
55
71
  const lastDpr = useRef(0);
@@ -80,13 +96,25 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
80
96
  dirty.current.clear();
81
97
  return;
82
98
  }
99
+ let retiled = false;
83
100
  if (needsMeasure.current) {
84
101
  needsMeasure.current = false;
85
- if (measure()) for (const id of rects.current.keys()) dirty.current.add(id);
102
+ if (measure()) {
103
+ retiled = true;
104
+ for (const id of rects.current.keys()) dirty.current.add(id);
105
+ // Geometry that changed may still be moving: a panel whose size has
106
+ // settled while its position animates gives `ResizeObserver` nothing
107
+ // more to report, and the tile would hold the frame it was caught
108
+ // mid-flight at. Measure again next frame, and keep going until a
109
+ // measurement finds nothing moved.
110
+ needsMeasure.current = true;
111
+ schedule();
112
+ }
86
113
  }
87
114
  const dpr = globalThis.devicePixelRatio ?? 1;
88
115
  if (dpr !== lastDpr.current) {
89
116
  lastDpr.current = dpr;
117
+ retiled = true;
90
118
  for (const id of rects.current.keys()) dirty.current.add(id);
91
119
  }
92
120
  if (dirty.current.size === 0) return;
@@ -96,14 +124,27 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
96
124
  rects: new Map(rects.current),
97
125
  dpr,
98
126
  size: { width: box.width, height: box.height },
127
+ retiled,
99
128
  };
129
+ // Cleared before the owner runs, not after the painters: sizing the
130
+ // buffer blanks every tile, so the owner answers with `invalidateAll`,
131
+ // and a set cleared at the end of the frame would swallow it.
132
+ dirty.current.clear();
100
133
  onFrameRef.current(frame);
101
- // After the owner, which is what resized the buffer this frame.
102
- for (const id of frame.dirty) {
103
- const rect = frame.rects.get(id);
104
- if (rect) painters.current.get(id)?.(rect, frame);
134
+ // After the owner sized the buffer, before anything paints into it.
135
+ if (retiled) {
136
+ for (const clear of clears.current.values()) clear(frame.size, dpr);
105
137
  }
138
+ // After the owner, which is what resized the buffer this frame — and
139
+ // whatever it just dirtied paints now rather than a frame later.
140
+ const painting = new Set(frame.dirty);
141
+ for (const id of dirty.current) painting.add(id);
106
142
  dirty.current.clear();
143
+ const paintFrame: SurfaceFrame = { ...frame, dirty: painting };
144
+ for (const id of painting) {
145
+ const rect = frame.rects.get(id);
146
+ if (rect) painters.current.get(id)?.(rect, paintFrame);
147
+ }
107
148
  },
108
149
  // The host attaches its container through `containerRef`, which may land
109
150
  // well after this hook's first effect; the gate re-resolves it per request.
@@ -151,6 +192,13 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
151
192
  [schedule],
152
193
  );
153
194
 
195
+ const registerClear = useCallback((id: string, clear: SurfaceClear) => {
196
+ clears.current.set(id, clear);
197
+ return () => {
198
+ if (clears.current.get(id) === clear) clears.current.delete(id);
199
+ };
200
+ }, []);
201
+
154
202
  const registerPainter = useCallback((id: string, paint: TilePainter) => {
155
203
  painters.current.set(id, paint);
156
204
  return () => {
@@ -192,6 +240,7 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
192
240
  invalidateAll,
193
241
  invalidateRects,
194
242
  registerTile,
243
+ registerClear,
195
244
  registerPainter,
196
245
  containerRef,
197
246
  getContainer,
@@ -55,16 +55,16 @@ const ACCENT_TOKENS: Swatch[] = [
55
55
  ];
56
56
 
57
57
  const PALETTE_TOKENS: Swatch[] = [
58
+ { name: 'fuchsia', cssVar: '--wzl-swatch-fuchsia' },
58
59
  { name: 'green', cssVar: '--wzl-swatch-green' },
59
- { name: 'pink', cssVar: '--wzl-swatch-pink' },
60
- { name: 'cyan', cssVar: '--wzl-swatch-cyan' },
61
- { name: 'gold', cssVar: '--wzl-swatch-gold' },
60
+ { name: 'sky', cssVar: '--wzl-swatch-sky' },
62
61
  { name: 'amber', cssVar: '--wzl-swatch-amber' },
62
+ { name: 'teal', cssVar: '--wzl-swatch-teal' },
63
+ { name: 'red', cssVar: '--wzl-swatch-red' },
64
+ { name: 'blue', cssVar: '--wzl-swatch-blue' },
65
+ { name: 'citron', cssVar: '--wzl-swatch-citron' },
66
+ { name: 'rose', cssVar: '--wzl-swatch-rose' },
63
67
  { name: 'violet', cssVar: '--wzl-swatch-violet' },
64
- { name: 'mint', cssVar: '--wzl-swatch-mint' },
65
- { name: 'sky', cssVar: '--wzl-swatch-sky' },
66
- { name: 'orange', cssVar: '--wzl-swatch-orange' },
67
- { name: 'magenta', cssVar: '--wzl-swatch-magenta' },
68
68
  ];
69
69
 
70
70
  function SwatchGrid({ tokens, title }: { tokens: readonly Swatch[]; title: string }) {
@@ -15,7 +15,7 @@ describe('interstellarTheme', () => {
15
15
 
16
16
  // Inherited from weasel — interstellar overrides values, not the token set.
17
17
  expect(dark['--wzl-space-md']).toBe('12px');
18
- expect(dark['--wzl-swatch-cyan']).toBe('#00dfff');
18
+ expect(dark['--wzl-swatch-teal']).toBe('#3ee1cb');
19
19
 
20
20
  // Deliberate divergences from the base.
21
21
  expect(dark['--wzl-radius-md']).toBe('6px');
@@ -30,7 +30,7 @@
30
30
  "accent-hover": { "$value": "#c6a3ee" },
31
31
  "focus-ring": { "$value": "rgba(176, 138, 219, 0.45)" },
32
32
  "shadow": { "$value": "rgba(0, 0, 0, 0.7)" },
33
- "border-raised": { "$value": "rgba(255, 255, 255, 0.36)" }
33
+ "border-strong": { "$value": "rgba(255, 255, 255, 0.36)" }
34
34
  },
35
35
  "gradient": {
36
36
  "$type": "gradient",
@@ -54,7 +54,7 @@
54
54
  "accent-hover": { "$value": "#c2854f" },
55
55
  "focus-ring": { "$value": "rgba(168, 111, 60, 0.35)" },
56
56
  "shadow": { "$value": "rgba(80, 60, 40, 0.18)" },
57
- "border-raised": { "$value": "#9e8b71" }
57
+ "border-strong": { "$value": "#9e8b71" }
58
58
  }
59
59
  }
60
60
  }
@@ -0,0 +1,11 @@
1
+ import type { InstrumentList } from '../instrument/types';
2
+
3
+ /**
4
+ * The tool the lab's slot holds, once its default is applied: a lab with any
5
+ * annotating instrument starts in `select`, so a first click on a picture
6
+ * selects rather than draws.
7
+ */
8
+ export function resolveLabTool(slot: string | null, instruments: InstrumentList): string | null {
9
+ if (slot !== null) return slot;
10
+ return instruments.some((i) => i.annotations != null) ? 'select' : null;
11
+ }
@@ -4,15 +4,16 @@
4
4
  * is written on a trailing debounce: a write per scene notification re-renders
5
5
  * every trial on every frame of a drag.
6
6
  */
7
- import { act, render } from '@testing-library/react';
7
+ import { act, render, screen, waitFor } from '@testing-library/react';
8
8
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
9
9
  import { useAnnotations } from '../annotations/AnnotationsContext';
10
10
  import type { AnnotationsApi, SerializedAnnotations } from '../annotations/types';
11
11
  import { defineInstrument } from '../instrument/defineInstrument';
12
12
  import type { InstrumentList } from '../instrument/types';
13
13
  import { Lab } from '../lab/Lab';
14
+ import { LabContext, type LabContextValue } from '../lab/LabContext';
14
15
  import { createMemoryAdapter } from '../state/adapters';
15
- import type { StorageAdapter } from '../state/types';
16
+ import { labPrefix } from '../state/labRecords';
16
17
 
17
18
  beforeAll(() => {
18
19
  HTMLCanvasElement.prototype.getContext = vi.fn(
@@ -21,12 +22,24 @@ beforeAll(() => {
21
22
  });
22
23
 
23
24
  let api: AnnotationsApi | null = null;
25
+ let lab: LabContextValue | null = null;
24
26
 
25
27
  function Pane() {
26
28
  api = useAnnotations();
27
29
  return <div data-testid="pane" />;
28
30
  }
29
31
 
32
+ function CaptureLab() {
33
+ return (
34
+ <LabContext.Consumer>
35
+ {(value) => {
36
+ lab = value;
37
+ return null;
38
+ }}
39
+ </LabContext.Consumer>
40
+ );
41
+ }
42
+
30
43
  /** Read through a call, so TypeScript does not narrow the module-level `api`
31
44
  * to `null` across the remounts these tests are about. */
32
45
  function marks(): AnnotationsApi {
@@ -46,6 +59,8 @@ const inspector = defineInstrument<Record<string, never>, { angle: number }>({
46
59
 
47
60
  /** The instrument keeps its own marks, so labkit must not keep them too. */
48
61
  let owned: SerializedAnnotations | null = null;
62
+ let loads = 0;
63
+ let gate: Promise<void> = Promise.resolve();
49
64
  const selfStoring = defineInstrument<Record<string, never>, Record<string, never>>({
50
65
  name: 'SelfStoring',
51
66
  defaultConfig: () => ({}),
@@ -54,7 +69,11 @@ const selfStoring = defineInstrument<Record<string, never>, Record<string, never
54
69
  annotations: {
55
70
  targets: () => [TARGET],
56
71
  storage: {
57
- load: () => owned,
72
+ load: async () => {
73
+ loads++;
74
+ await gate;
75
+ return owned;
76
+ },
58
77
  save: (doc) => {
59
78
  owned = doc;
60
79
  },
@@ -62,42 +81,51 @@ const selfStoring = defineInstrument<Record<string, never>, Record<string, never
62
81
  },
63
82
  });
64
83
 
65
- function mount(storage: StorageAdapter, which: InstrumentList[number] = inspector) {
66
- return render(
84
+ async function mount(backing: Map<string, unknown>, which: InstrumentList[number] = inspector) {
85
+ api = null;
86
+ const view = render(
67
87
  <Lab
68
88
  instruments={[which]}
69
89
  defaultInstrument={which.name}
70
- storage={storage}
90
+ storage={createMemoryAdapter(backing)}
71
91
  storageKey="persist-test"
72
- />,
92
+ >
93
+ <CaptureLab />
94
+ </Lab>,
73
95
  );
96
+ await waitFor(() => expect(api).not.toBeNull());
97
+ return view;
98
+ }
99
+
100
+ function storedTrial(backing: Map<string, unknown>): Record<string, unknown> | undefined {
101
+ const prefix = `${labPrefix('persist-test')}trial:`;
102
+ return [...backing].find(([key]) => key.startsWith(prefix))?.[1] as
103
+ | Record<string, unknown>
104
+ | undefined;
74
105
  }
75
106
 
76
107
  const MARK = { target: 'pane', kind: 'rect' as const, frac: { x: 0.2, y: 0.3, w: 0.1, h: 0.1 } };
77
108
 
78
109
  describe('a trial that persists its marks', () => {
79
110
  beforeEach(() => {
80
- vi.useFakeTimers();
81
111
  api = null;
112
+ lab = null;
82
113
  owned = null;
114
+ loads = 0;
115
+ gate = Promise.resolve();
83
116
  });
84
117
  afterEach(() => {
85
118
  vi.useRealTimers();
86
119
  });
87
120
 
88
- it('brings them back on a remount from the same storage', () => {
89
- const storage = createMemoryAdapter();
90
- const first = mount(storage);
121
+ it('brings them back on a remount from the same storage', async () => {
122
+ const backing = new Map<string, unknown>();
123
+ const first = await mount(backing);
91
124
  const id = marks().add(MARK, { angle: 12 });
92
-
93
- // Past both debounces: labkit's write-back, then the document flush.
94
- act(() => {
95
- vi.advanceTimersByTime(1000);
96
- });
97
125
  first.unmount();
126
+ await waitFor(() => expect(storedTrial(backing)?.annotations).toBeDefined());
98
127
 
99
- api = null;
100
- mount(storage);
128
+ await mount(backing);
101
129
  const back = marks().get(id);
102
130
  expect(back).toMatchObject({ id, target: 'pane', kind: 'rect', frac: MARK.frac });
103
131
  // The staleness snapshot has to survive too, or every restored mark reads
@@ -107,60 +135,74 @@ describe('a trial that persists its marks', () => {
107
135
  expect(marks().isStale(back, { angle: 45 })).toBe(true);
108
136
  });
109
137
 
110
- it('writes nothing before the debounce elapses', () => {
111
- const storage = createMemoryAdapter();
112
- const first = mount(storage);
138
+ it('keeps a mark out of storage until both debounces pass, while the lab stays open', async () => {
139
+ const backing = new Map<string, unknown>();
140
+ await mount(backing);
141
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
113
142
  marks().add(MARK);
114
- act(() => {
115
- vi.advanceTimersByTime(20);
116
- });
117
- first.unmount();
118
-
119
- api = null;
120
- mount(storage);
121
- // Not a claim about the debounce interval — a claim that one exists, and
122
- // that a store write is not synchronous with a scene notification.
123
- //
124
- // It is also the no-migration claim: the document this remount reads was
125
- // written with trials and no `annotations` field, exactly as every
126
- // document from before this arc is, and the trial opens on an empty store
127
- // rather than failing to load.
128
- expect(marks().query()).toHaveLength(0);
143
+ await act(() => vi.advanceTimersByTimeAsync(20));
144
+ // Not a claim about either interval — a claim that a store write is not
145
+ // synchronous with a scene notification.
146
+ expect(storedTrial(backing)?.annotations).toBeUndefined();
147
+ await act(() => vi.advanceTimersByTimeAsync(2000));
148
+ expect(storedTrial(backing)?.annotations).toBeDefined();
129
149
  });
130
150
 
131
- it('flushes on unmount, so the last mark before a close is not lost', () => {
132
- const storage = createMemoryAdapter();
133
- const first = mount(storage);
151
+ it('flushes on unmount, so the last mark before a close is not lost', async () => {
152
+ const backing = new Map<string, unknown>();
153
+ const first = await mount(backing);
134
154
  const id = marks().add(MARK);
135
- act(() => {
136
- vi.advanceTimersByTime(500);
137
- });
138
155
  marks().add({ ...MARK, frac: { x: 0.6, y: 0.6, w: 0.1, h: 0.1 } });
139
156
  first.unmount();
140
- act(() => {
141
- vi.advanceTimersByTime(1000);
142
- });
157
+ // Nothing was written before the unmount: neither debounce had elapsed.
158
+ await waitFor(() => expect(storedTrial(backing)?.annotations).toBeDefined());
143
159
 
144
- api = null;
145
- mount(storage);
160
+ await mount(backing);
146
161
  expect(marks().query()).toHaveLength(2);
147
162
  expect(marks().get(id)).toBeDefined();
148
163
  });
149
164
 
150
- it('leaves the slot alone when the instrument owns the storage', () => {
151
- const storage = createMemoryAdapter();
152
- const first = mount(storage, selfStoring);
165
+ it('leaves the slot alone when the instrument owns the storage', async () => {
166
+ const backing = new Map<string, unknown>();
167
+ const first = await mount(backing, selfStoring);
153
168
  marks().add(MARK);
154
- act(() => {
155
- vi.advanceTimersByTime(1000);
156
- });
157
169
  first.unmount();
170
+ await waitFor(() => expect(owned?.version).toBe(1));
171
+ await waitFor(() => expect(storedTrial(backing)).toBeDefined());
172
+ expect(storedTrial(backing)).not.toHaveProperty('annotations');
158
173
 
159
- expect(owned?.version).toBe(1);
160
- expect(storage.read('lk:persist-test:doc')).not.toContain('"annotations"');
161
-
162
- api = null;
163
- mount(storage, selfStoring);
174
+ await mount(backing, selfStoring);
164
175
  expect(marks().query()).toHaveLength(1);
165
176
  });
177
+
178
+ it('loads instrument-owned marks before the lab shows, and a later trial before its body', async () => {
179
+ const backing = new Map<string, unknown>();
180
+ let release = (): void => {};
181
+ gate = new Promise((resolve) => {
182
+ release = resolve;
183
+ });
184
+ render(
185
+ <Lab
186
+ instruments={[selfStoring]}
187
+ defaultInstrument="SelfStoring"
188
+ storage={createMemoryAdapter(backing)}
189
+ storageKey="persist-test"
190
+ >
191
+ <CaptureLab />
192
+ </Lab>,
193
+ );
194
+ await waitFor(() => expect(loads).toBe(1));
195
+ expect(screen.queryByTestId('pane')).toBeNull();
196
+ await act(async () => release());
197
+ expect(await screen.findByTestId('pane')).toBeInTheDocument();
198
+
199
+ gate = new Promise((resolve) => {
200
+ release = resolve;
201
+ });
202
+ act(() => lab?.addTrial('SelfStoring'));
203
+ expect(document.querySelectorAll('.lk-trial--loading')).toHaveLength(1);
204
+ await act(async () => release());
205
+ await waitFor(() => expect(screen.getAllByTestId('pane')).toHaveLength(2));
206
+ expect(loads).toBe(2);
207
+ });
166
208
  });