@weasel-js/labkit 1.3.0 → 1.4.0-pre.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 (211) hide show
  1. package/README.md +226 -55
  2. package/dist/_dts/CanvasStackContext-LnCfqNBA.d.ts +42 -0
  3. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  4. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  5. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  6. package/dist/_dts/frac-C-2c72Ij.d.ts +735 -0
  7. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  8. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  9. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  10. package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
  11. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  12. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  13. package/dist/canvas/index.d.ts +110 -26
  14. package/dist/canvas/index.js +5 -4
  15. package/dist/chrome/index.d.ts +9 -6
  16. package/dist/chrome/index.js +6 -6
  17. package/dist/chunk-2AYGEN57.js +32 -0
  18. package/dist/chunk-2AYGEN57.js.map +1 -0
  19. package/dist/chunk-6OEYTYML.js +669 -0
  20. package/dist/chunk-6OEYTYML.js.map +1 -0
  21. package/dist/{chunk-BMW4TDP5.js → chunk-AE5CNVRU.js} +3 -3
  22. package/dist/{chunk-BMW4TDP5.js.map → chunk-AE5CNVRU.js.map} +1 -1
  23. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  24. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  25. package/dist/chunk-CTRKTLYZ.js +33 -0
  26. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  27. package/dist/chunk-E2UQVZ44.js +458 -0
  28. package/dist/chunk-E2UQVZ44.js.map +1 -0
  29. package/dist/{chunk-LGKRIUSW.js → chunk-ESIQNQ6K.js} +32 -7
  30. package/dist/chunk-ESIQNQ6K.js.map +1 -0
  31. package/dist/{chunk-JDROYQM3.js → chunk-HHGVISVZ.js} +130 -131
  32. package/dist/chunk-HHGVISVZ.js.map +1 -0
  33. package/dist/{chunk-BDJXIBRZ.js → chunk-HMFODCOX.js} +126 -29
  34. package/dist/chunk-HMFODCOX.js.map +1 -0
  35. package/dist/{chunk-QCICIHEO.js → chunk-L5QJOOLV.js} +839 -320
  36. package/dist/chunk-L5QJOOLV.js.map +1 -0
  37. package/dist/{chunk-3XXPU73K.js → chunk-M75ZU6ZZ.js} +9 -7
  38. package/dist/chunk-M75ZU6ZZ.js.map +1 -0
  39. package/dist/{chunk-QRYPXSGP.js → chunk-QB5SM4BM.js} +16 -8
  40. package/dist/chunk-QB5SM4BM.js.map +1 -0
  41. package/dist/chunk-RBGKL7NF.js +7128 -0
  42. package/dist/chunk-RBGKL7NF.js.map +1 -0
  43. package/dist/{chunk-WTI26YTM.js → chunk-TO2FUOKF.js} +40 -13
  44. package/dist/chunk-TO2FUOKF.js.map +1 -0
  45. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  46. package/dist/chunk-XJ6N32QP.js.map +1 -0
  47. package/dist/controls/index.d.ts +4 -28
  48. package/dist/controls/index.js +3 -3
  49. package/dist/dragdrop/index.d.ts +9 -5
  50. package/dist/dragdrop/index.js +3 -3
  51. package/dist/index.d.ts +517 -234
  52. package/dist/index.js +1769 -143
  53. package/dist/index.js.map +1 -1
  54. package/dist/layers/index.d.ts +6 -5
  55. package/dist/layers/index.js +3 -3
  56. package/dist/loupe/index.d.ts +187 -0
  57. package/dist/loupe/index.js +7 -0
  58. package/dist/loupe/index.js.map +1 -0
  59. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  60. package/dist/passthrough/weasel-canvas.js +1 -1
  61. package/dist/passthrough/weasel-ui.d.ts +138 -91
  62. package/dist/passthrough/weasel-ui.js +2 -2
  63. package/dist/primitives/index.d.ts +3 -1
  64. package/dist/primitives/index.js +5 -5
  65. package/dist/state/index.d.ts +3 -3
  66. package/dist/state/index.js +2 -2
  67. package/dist/styles.css +229 -1
  68. package/dist/surface/index.d.ts +18 -2
  69. package/dist/surface/index.js +2 -2
  70. package/dist/ui/layers/index.js +2 -2
  71. package/dist/undo/index.d.ts +5 -4
  72. package/package.json +14 -7
  73. package/src/annotations/AnnotationOverlay.tsx +205 -0
  74. package/src/annotations/AnnotationTargets.tsx +37 -0
  75. package/src/annotations/Annotations.less +129 -0
  76. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  78. package/src/annotations/AnnotationsContext.ts +49 -0
  79. package/src/annotations/ExportMenu.test.tsx +86 -0
  80. package/src/annotations/ExportMenu.tsx +182 -0
  81. package/src/annotations/MarkList.tsx +75 -0
  82. package/src/annotations/capture.test.ts +140 -0
  83. package/src/annotations/capture.ts +237 -0
  84. package/src/annotations/drawOne.test.ts +56 -0
  85. package/src/annotations/drawOne.ts +47 -0
  86. package/src/annotations/frac.test.ts +59 -0
  87. package/src/annotations/frac.ts +66 -0
  88. package/src/annotations/history.test.ts +82 -0
  89. package/src/annotations/history.ts +118 -0
  90. package/src/annotations/index.ts +37 -0
  91. package/src/annotations/paint.test.ts +125 -0
  92. package/src/annotations/paint.ts +130 -0
  93. package/src/annotations/staleness.test.ts +56 -0
  94. package/src/annotations/staleness.ts +41 -0
  95. package/src/annotations/store.test.ts +247 -0
  96. package/src/annotations/store.ts +347 -0
  97. package/src/annotations/svgNodes.test.ts +77 -0
  98. package/src/annotations/svgNodes.ts +74 -0
  99. package/src/annotations/toolMap.test.ts +36 -0
  100. package/src/annotations/toolMap.ts +54 -0
  101. package/src/annotations/types.ts +229 -0
  102. package/src/annotations/view.test.ts +37 -0
  103. package/src/annotations/view.ts +40 -0
  104. package/src/canvas/AGENTS.md +55 -5
  105. package/src/canvas/CanvasStack.test.tsx +27 -1
  106. package/src/canvas/CanvasStack.tsx +29 -4
  107. package/src/canvas/CanvasStackContext.ts +23 -4
  108. package/src/canvas/camera.test.ts +78 -0
  109. package/src/canvas/camera.ts +62 -0
  110. package/src/canvas/canvasCoords.test.ts +26 -0
  111. package/src/canvas/canvasCoords.ts +20 -7
  112. package/src/canvas/index.ts +16 -1
  113. package/src/canvas/useLayerScheduler.ts +7 -3
  114. package/src/canvas/usePanZoom.test.ts +36 -5
  115. package/src/canvas/usePanZoom.ts +20 -15
  116. package/src/canvas/worldSpec.test.ts +63 -0
  117. package/src/canvas/worldSpec.ts +48 -0
  118. package/src/chrome/builtins.test.ts +31 -1
  119. package/src/chrome/builtins.tsx +63 -19
  120. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  121. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  122. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  123. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  124. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  125. package/src/chrome/regions/regions.test.tsx +6 -1
  126. package/src/chrome/types.ts +20 -0
  127. package/src/controls/ControlPanel.test.tsx +38 -0
  128. package/src/controls/ControlPanel.tsx +50 -3
  129. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  130. package/src/dragdrop/dragDrop.test.tsx +29 -2
  131. package/src/index.test.ts +23 -0
  132. package/src/index.ts +93 -4
  133. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  134. package/src/instrument/types.ts +24 -1
  135. package/src/lab/Lab.surface.test.tsx +162 -0
  136. package/src/lab/Lab.tsx +102 -17
  137. package/src/lab/Lab.undock.test.tsx +85 -0
  138. package/src/lab/LabFullChrome.stories.tsx +20 -0
  139. package/src/lab/LabShell.less +11 -0
  140. package/src/lab/Workspace.less +73 -0
  141. package/src/lab/Workspace.surface.test.tsx +2 -0
  142. package/src/lab/Workspace.tsx +97 -8
  143. package/src/lab/index.ts +1 -1
  144. package/src/lab/panelHost.ts +37 -0
  145. package/src/loupe/AGENTS.md +49 -0
  146. package/src/loupe/CanvasLoupe.tsx +83 -0
  147. package/src/loupe/DomLoupe.tsx +62 -0
  148. package/src/loupe/Loupe.less +38 -0
  149. package/src/loupe/LoupeBubble.tsx +32 -0
  150. package/src/loupe/TrialLoupe.tsx +93 -0
  151. package/src/loupe/canvasLens.test.ts +205 -0
  152. package/src/loupe/canvasLens.ts +141 -0
  153. package/src/loupe/index.ts +19 -0
  154. package/src/loupe/types.test.ts +29 -0
  155. package/src/loupe/types.ts +90 -0
  156. package/src/loupe/useHostSize.ts +30 -0
  157. package/src/loupe/useLoupe.test.tsx +197 -0
  158. package/src/loupe/useLoupe.ts +189 -0
  159. package/src/passthrough/weasel-ui.ts +3 -0
  160. package/src/primitives/FloatingPanel.test.tsx +76 -1
  161. package/src/primitives/FloatingPanel.tsx +19 -3
  162. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  163. package/src/primitives/Sidebar.less +18 -0
  164. package/src/primitives/Toolbar.less +8 -0
  165. package/src/primitives/Toolbar.tsx +5 -0
  166. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  167. package/src/primitives/useRovingTabIndex.ts +26 -17
  168. package/src/state/document.test.ts +23 -0
  169. package/src/state/document.ts +14 -2
  170. package/src/state/index.ts +2 -0
  171. package/src/state/store.test.ts +24 -0
  172. package/src/state/store.ts +35 -1
  173. package/src/state/types.ts +11 -0
  174. package/src/state/undock.test.ts +40 -0
  175. package/src/state/undock.ts +39 -0
  176. package/src/styles.less +2 -0
  177. package/src/surface/SurfaceContext.ts +4 -0
  178. package/src/surface/index.ts +8 -3
  179. package/src/surface/useSurfaceTile.test.tsx +2 -0
  180. package/src/surface/useSurfaceTile.ts +7 -1
  181. package/src/surface/useTiledSurface.test.tsx +58 -0
  182. package/src/surface/useTiledSurface.ts +38 -3
  183. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  184. package/src/trial/Trial.annotations.test.tsx +121 -0
  185. package/src/trial/Trial.canvas.test.tsx +76 -1
  186. package/src/trial/Trial.less +19 -0
  187. package/src/trial/Trial.loupe.test.tsx +128 -0
  188. package/src/trial/Trial.stories.tsx +1 -0
  189. package/src/trial/Trial.test.tsx +12 -0
  190. package/src/trial/Trial.trialId.test.tsx +36 -0
  191. package/src/trial/Trial.tsx +215 -40
  192. package/src/trial/TrialChrome.tsx +35 -0
  193. package/src/trial/UndockedSections.tsx +65 -0
  194. package/src/trial/trialOps.test.ts +21 -0
  195. package/src/trial/trialOps.ts +5 -3
  196. package/dist/_dts/types-BttTIent.d.ts +0 -314
  197. package/dist/chunk-3XXPU73K.js.map +0 -1
  198. package/dist/chunk-4TMMVIDM.js.map +0 -1
  199. package/dist/chunk-6X5RBC6G.js +0 -366
  200. package/dist/chunk-6X5RBC6G.js.map +0 -1
  201. package/dist/chunk-BDJXIBRZ.js.map +0 -1
  202. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  203. package/dist/chunk-JDROYQM3.js.map +0 -1
  204. package/dist/chunk-LGKRIUSW.js.map +0 -1
  205. package/dist/chunk-QCICIHEO.js.map +0 -1
  206. package/dist/chunk-QRYPXSGP.js.map +0 -1
  207. package/dist/chunk-RGT4EJIB.js +0 -6961
  208. package/dist/chunk-RGT4EJIB.js.map +0 -1
  209. package/dist/chunk-T66HU3HX.js +0 -17
  210. package/dist/chunk-T66HU3HX.js.map +0 -1
  211. package/dist/chunk-WTI26YTM.js.map +0 -1
package/src/index.ts CHANGED
@@ -47,17 +47,103 @@ export {
47
47
  TextRow,
48
48
  ToggleRow,
49
49
  } from '@weasel-js/ui';
50
- export type { CanvasLayerDescriptor, CanvasStackContextValue, CanvasStackProps } from './canvas';
51
- export { CanvasStack, CanvasStackContext, screenToWorld, worldToScreen } from './canvas';
52
- export type { OrbitHandlers, OrbitView, UseOrbitOptions, Vec3 } from './canvas/useOrbit';
50
+ export type {
51
+ Annotation,
52
+ AnnotationData,
53
+ AnnotationInit,
54
+ AnnotationKind,
55
+ AnnotationMeaning,
56
+ AnnotationPatch,
57
+ AnnotationQuery,
58
+ AnnotationStatus,
59
+ AnnotationStoreOptions,
60
+ AnnotationsApi,
61
+ AnnotationsCapability,
62
+ AnnotationTarget,
63
+ AnnotationTargetInfo,
64
+ CaptureDeps,
65
+ CaptureOptions,
66
+ CaptureResult,
67
+ CaptureSource,
68
+ FracPoint,
69
+ FracRect,
70
+ MarkScene,
71
+ SerializedAnnotations,
72
+ WorldRect,
73
+ } from './annotations';
74
+ export {
75
+ annotationsFromJSON,
76
+ createAnnotationScene,
77
+ createAnnotationStore,
78
+ fracContains,
79
+ fracIntersects,
80
+ fracToWorld,
81
+ isStale,
82
+ roundFrac,
83
+ seenFrom,
84
+ worldToFrac,
85
+ } from './annotations';
86
+ export type { AnnotationOverlayProps } from './annotations/AnnotationOverlay';
87
+ export { AnnotationOverlay } from './annotations/AnnotationOverlay';
88
+ export {
89
+ AnnotationsContext,
90
+ useAnnotations,
91
+ useAnnotationsOptional,
92
+ } from './annotations/AnnotationsContext';
93
+ export type { AnnotationTargetsProps } from './annotations/AnnotationTargets';
94
+ export { AnnotationTargets } from './annotations/AnnotationTargets';
95
+ export type { CaptureArgs, CapturePlan, ComposeSvgArgs } from './annotations/capture';
96
+ export { capturePlan, captureTarget, composeCaptureSvg } from './annotations/capture';
97
+ export type { MarkDrawOptions } from './annotations/drawOne';
98
+ export { createMarkDrawOne, resolveMarkStyle } from './annotations/drawOne';
99
+ export { ExportMenu } from './annotations/ExportMenu';
100
+ export type { MarkListProps } from './annotations/MarkList';
101
+ export { MarkList } from './annotations/MarkList';
102
+ export type { MarkStyle, PaintableMark } from './annotations/paint';
103
+ export { markCommands } from './annotations/paint';
104
+ export { markSvgNodes } from './annotations/svgNodes';
105
+ export type { AnnotationToolInfo } from './annotations/toolMap';
106
+ export {
107
+ ANNOTATION_TOOLS,
108
+ ANNOTATION_WEASEL_TOOLS,
109
+ annotationToolInfo,
110
+ } from './annotations/toolMap';
111
+ export type { ContentSize, PaneSize } from './annotations/view';
112
+ export { fitView, fromWeaselView, toWeaselView } from './annotations/view';
113
+ export type {
114
+ CanvasLayerDescriptor,
115
+ CanvasStackContextValue,
116
+ CanvasStackProps,
117
+ CanvasStackSurface,
118
+ OrbitHandlers,
119
+ OrbitView,
120
+ PanZoomHandlers,
121
+ UseOrbitOptions,
122
+ UsePanZoomOptions,
123
+ Vec3,
124
+ ViewportSize,
125
+ WorldFrame,
126
+ WorldSpec,
127
+ ZoomAtOptions,
128
+ } from './canvas';
53
129
  export {
130
+ applyCamera,
131
+ CanvasStack,
132
+ CanvasStackContext,
133
+ centerOn,
54
134
  clampPitch,
135
+ DEFAULT_FRAME,
55
136
  orbitAfterDrag,
56
137
  orbitAfterWheel,
57
138
  PITCH_LIMIT,
139
+ resolveFrame,
140
+ screenToWorld,
58
141
  useOrbit,
142
+ usePanZoom,
143
+ worldToScreen,
59
144
  wrapYaw,
60
- } from './canvas/useOrbit';
145
+ zoomAt,
146
+ } from './canvas';
61
147
  export * from './chrome';
62
148
  export { f } from './config/builder';
63
149
  export { fromConfigFields } from './config/fromConfigField';
@@ -118,6 +204,7 @@ export * from './job';
118
204
  export * from './lab';
119
205
  export type { LayerListProps } from './layers';
120
206
  export { LayerList } from './layers';
207
+ export * from './loupe';
121
208
  export * from './primitives';
122
209
  export {
123
210
  createMemoryAdapter,
@@ -147,6 +234,8 @@ export type {
147
234
  TrialStateHandle,
148
235
  UndoStack,
149
236
  } from './state/types';
237
+ export type { UndockedPanel, UndockedPanels } from './state/undock';
238
+ export { dockPanel, panelKey, undockPanel } from './state/undock';
150
239
  export { useTrialState } from './state/useTrialState';
151
240
  export type { ViewTransform2D } from './state/view';
152
241
  export { as2DView, DEFAULT_VIEW } from './state/view';
@@ -93,6 +93,7 @@ describe('SineWave instrument smoke test', () => {
93
93
  zoom: 1,
94
94
  setZoom: vi.fn(),
95
95
  activeToolId: null,
96
+ visibleLayers: [],
96
97
  },
97
98
  emit: vi.fn(),
98
99
  };
@@ -1,8 +1,11 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { AnnotationsCapability } from '../annotations/types';
3
+ import type { ViewportSize, WorldSpec } from '../canvas/worldSpec';
2
4
  import type { TrialContribution } from '../chrome/types';
3
5
  import type { ConfigSchema } from '../config/types';
4
6
  import type { ConfigField } from '../controls/types';
5
7
  import type { JobCapability, JobHandle } from '../job/types';
8
+ import type { LoupeDeclaration } from '../loupe/types';
6
9
  import type { ToolCapability } from '../tools/types';
7
10
 
8
11
  /** What an instrument's `render` is handed: its state and config, the setters
@@ -25,6 +28,10 @@ export interface RenderContext<TS = unknown, TC = unknown> {
25
28
  /** Resolved active tool: this trial's slot, or the lab's. Null when neither
26
29
  * holds one. */
27
30
  activeToolId: string | null;
31
+ /** The canvas layers currently shown, in declaration order. labkit skips a
32
+ * hidden layer's `draw`, so this is the only way to know what was
33
+ * painted — a legend listing rows the view did not draw needs it. */
34
+ visibleLayers: readonly string[];
28
35
  };
29
36
  emit: (event: string) => void;
30
37
  /** Present only when the instrument declares a `job`. */
@@ -45,7 +52,14 @@ export interface CanvasLayer<TS = unknown, TC = unknown> {
45
52
  * view starts. */
46
53
  export interface CanvasCapability<TS = unknown, TC = unknown> {
47
54
  layers: CanvasLayer<TS, TC>[];
48
- initialView?: { zoom: number; pan: { x: number; y: number } };
55
+ /** The coordinate system this instrument's world is in. Omitted, world (0,0)
56
+ * sits at the canvas top-left with y running down — what labkit assumed
57
+ * before an instrument could say otherwise. */
58
+ worldSpec?: WorldSpec;
59
+ /** Where the view starts. A function is called once the canvas knows its
60
+ * size, so an instrument can frame content it can only place in terms of
61
+ * the viewport; until then the trial's view is `null`. */
62
+ initialView?: ViewTransform | ((size: ViewportSize) => ViewTransform);
49
63
  /** Widens `usePanZoom`'s default clamp; the opening zoom stays reachable
50
64
  * regardless of these. */
51
65
  minZoom?: number;
@@ -127,6 +141,15 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
127
141
  /** Tools this instrument offers. Declaring them gives the trial a palette
128
142
  * region and its own tool slot. */
129
143
  tools?: ToolCapability;
144
+ /** Regions of this instrument that accept marks, and optionally what a mark
145
+ * is allowed to mean. Declaring it is what makes the trial provide the
146
+ * annotation overlay and its chrome. */
147
+ annotations?: AnnotationsCapability<TS, TC>;
148
+ /** Magnification. `true` takes every default and re-draws the instrument's
149
+ * canvas layers through a zoomed camera; an instrument whose content is DOM
150
+ * gives a `render` that draws it again at a camera it is handed. A function
151
+ * is re-read as the config changes, so a setting can drive the lens. */
152
+ loupe?: LoupeDeclaration<TS, TC> | ((config: TC) => LoupeDeclaration<TS, TC>);
130
153
  /** Chrome this instrument contributes beyond what its capabilities imply. */
131
154
  chrome?: TrialContribution[];
132
155
  /** Work too slow to do during a render. The runtime starts it, aborts it on
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `useTiledSurface` was built, exported and tested, but nothing mounted a
3
+ * `SurfaceContext` in production — so `useSurfaceOptional()` always answered
4
+ * null and a tile registered from inside a trial reached nothing.
5
+ *
6
+ * The surface is anchored to `.lk-lab__body` rather than wrapped around the
7
+ * workspace: `.lk-lab__body > .lk-workspace { min-width: 0 }` is a direct-child
8
+ * selector, so an extra element between them silently drops that rule.
9
+ */
10
+ import { act, render } from '@testing-library/react';
11
+ import type { ReactNode } from 'react';
12
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { defineInstrument } from '../instrument/defineInstrument';
14
+ import { SurfaceContext } from '../surface/SurfaceContext';
15
+ import { useSurfaceOptional, useSurfaceTile } from '../surface/useSurfaceTile';
16
+ import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
17
+ import { useTiledSurface } from '../surface/useTiledSurface';
18
+ import { Lab } from './Lab';
19
+
20
+ /** jsdom measures everything as zero, so each element is given a box by hand. */
21
+ function stubBox(el: HTMLElement, left: number, top: number, width: number, height: number): void {
22
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
23
+ left,
24
+ top,
25
+ width,
26
+ height,
27
+ right: left + width,
28
+ bottom: top + height,
29
+ x: left,
30
+ y: top,
31
+ toJSON: () => ({}),
32
+ } as DOMRect);
33
+ }
34
+
35
+ beforeAll(() => {
36
+ HTMLCanvasElement.prototype.getContext = vi.fn(
37
+ () => null,
38
+ ) as unknown as HTMLCanvasElement['getContext'];
39
+ });
40
+
41
+ let seen: SurfaceHandle | null | 'not-rendered' = 'not-rendered';
42
+
43
+ function Probe() {
44
+ seen = useSurfaceOptional();
45
+ const tile = useSurfaceTile('pane');
46
+ return <div data-testid="pane" ref={tile} />;
47
+ }
48
+
49
+ const probeInstrument = defineInstrument<Record<string, never>, Record<string, never>>({
50
+ name: 'Probe',
51
+ defaultConfig: () => ({}),
52
+ initialState: () => ({}),
53
+ render: () => <Probe />,
54
+ });
55
+
56
+ describe('<Lab> surface provider', () => {
57
+ it('puts a surface handle in reach of an instrument', () => {
58
+ seen = 'not-rendered';
59
+ render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
60
+ expect(seen).not.toBe('not-rendered');
61
+ expect(seen).not.toBeNull();
62
+ expect(typeof (seen as unknown as SurfaceHandle).registerTile).toBe('function');
63
+ });
64
+
65
+ it('mounts one inert buffer inside the lab body', () => {
66
+ const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
67
+ const canvases = container.querySelectorAll('.lk-lab__body > canvas.lk-lab__surface');
68
+ expect(canvases).toHaveLength(1);
69
+ });
70
+
71
+ it('mounts no buffer of its own when a host already owns the surface', () => {
72
+ function Host({ children }: { children: ReactNode }) {
73
+ const surface = useTiledSurface({ onFrame: () => {} });
74
+ return (
75
+ <SurfaceContext.Provider value={surface}>
76
+ <div ref={surface.containerRef}>{children}</div>
77
+ </SurfaceContext.Provider>
78
+ );
79
+ }
80
+ const { container } = render(
81
+ <Host>
82
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />
83
+ </Host>,
84
+ );
85
+ expect(container.querySelectorAll('canvas.lk-lab__surface')).toHaveLength(0);
86
+ });
87
+
88
+ it('keeps the workspace a direct child of the lab body', () => {
89
+ // The min-width:0 rule that keeps the flex row from overflowing is
90
+ // `.lk-lab__body > .lk-workspace`. An element inserted between them drops
91
+ // it, and jsdom cannot see the overflow that follows.
92
+ const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
93
+ const body = container.querySelector('.lk-lab__body');
94
+ expect(body).not.toBeNull();
95
+ expect(body?.querySelector(':scope > .lk-workspace')).not.toBeNull();
96
+ });
97
+ });
98
+
99
+ describe('<Lab> tile round trip', () => {
100
+ beforeEach(() => {
101
+ vi.useFakeTimers();
102
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
103
+ setTimeout(() => cb(0), 16),
104
+ );
105
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
106
+ });
107
+ afterEach(() => {
108
+ vi.useRealTimers();
109
+ vi.unstubAllGlobals();
110
+ vi.restoreAllMocks();
111
+ });
112
+
113
+ it('publishes a rect for a tile an instrument registered', () => {
114
+ const frames: SurfaceFrame[] = [];
115
+ let handle: SurfaceHandle | null = null;
116
+
117
+ function Host({ children }: { children: ReactNode }) {
118
+ const surface = useTiledSurface({ onFrame: (f) => frames.push(f) });
119
+ handle = surface;
120
+ return (
121
+ <SurfaceContext.Provider value={surface}>
122
+ <div
123
+ ref={(el) => {
124
+ if (!el) return;
125
+ stubBox(el, 0, 0, 800, 600);
126
+ surface.containerRef(el);
127
+ }}
128
+ >
129
+ {children}
130
+ </div>
131
+ </SurfaceContext.Provider>
132
+ );
133
+ }
134
+
135
+ render(
136
+ <Host>
137
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />
138
+ </Host>,
139
+ );
140
+
141
+ // The instrument's own div is the tile. Give it a box the container did
142
+ // not have, so a rect composed against the wrong origin is visible.
143
+ const pane = document.querySelector('[data-testid="pane"]') as HTMLElement;
144
+ expect(pane).not.toBeNull();
145
+ stubBox(pane, 240, 100, 320, 200);
146
+
147
+ act(() => {
148
+ handle?.invalidateRects();
149
+ handle?.invalidate('pane');
150
+ });
151
+ act(() => {
152
+ vi.advanceTimersByTime(64);
153
+ });
154
+
155
+ // Assert the frame fired at all before asserting its contents — a test
156
+ // that passes because nothing ran is the failure mode this arc keeps
157
+ // hitting.
158
+ expect(frames.length).toBeGreaterThan(0);
159
+ const rect = frames[frames.length - 1]?.rects.get('pane');
160
+ expect(rect).toEqual({ x: 240, y: 100, w: 320, h: 200 });
161
+ });
162
+ });
package/src/lab/Lab.tsx CHANGED
@@ -1,5 +1,13 @@
1
1
  import { ThemeProvider } from '@weasel-js/theme/react';
2
- import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
2
+ import {
3
+ type CSSProperties,
4
+ type ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
3
11
  import { useStore } from 'zustand/react';
4
12
  import type { TrialContribution } from '../chrome/types';
5
13
  import type { ConfigRule, ControlRenderer } from '../config/types';
@@ -8,6 +16,9 @@ import { noneAdapter } from '../state/adapters';
8
16
  import { LabStoreContext } from '../state/context';
9
17
  import { createLabStore, type LabStore } from '../state/store';
10
18
  import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
19
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
20
+ import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
21
+ import { useTiledSurface } from '../surface/useTiledSurface';
11
22
  import { interstellarTheme } from '../theme/interstellar';
12
23
  import type { TrialTool } from '../tools/types';
13
24
  import { Trial } from '../trial/Trial';
@@ -22,8 +33,9 @@ import { LabContext, type LabContextValue } from './LabContext';
22
33
  import { LabHeader } from './LabHeader';
23
34
  import { LabPalette } from './LabPalette';
24
35
  import { LabShell } from './LabShell';
36
+ import { createPanelHostRegistry, PanelHostContext } from './panelHost';
25
37
  import { useResolvedMode } from './useSystemMode';
26
- import { type TrialLayout, Workspace } from './Workspace';
38
+ import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
27
39
 
28
40
  /** Props for `<Lab>`. */
29
41
  export interface LabProps {
@@ -137,6 +149,57 @@ export function Lab({
137
149
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
138
150
  const modeValue = useStore(store, (s) => s.mode);
139
151
  const layout = useStore(store, (s) => s.layout);
152
+ const undockedPanels = useStore(store, (s) => s.undockedPanels);
153
+
154
+ // One registry for the lab's lifetime: a panel host is a DOM node the
155
+ // workspace owns and the trial portals into, so it must outlive both ends of
156
+ // a dock/undock without being rebuilt.
157
+ const panelHostsRef = useRef<ReturnType<typeof createPanelHostRegistry> | null>(null);
158
+ if (panelHostsRef.current === null) panelHostsRef.current = createPanelHostRegistry();
159
+
160
+ // One shared drawing surface for the whole lab, anchored to the body — tile
161
+ // rects compose against it, and every tile paints into the one buffer.
162
+ // `Workspace` invalidates rects when the grid moves something a
163
+ // ResizeObserver cannot see. A host that already owns a surface keeps it: a
164
+ // lab embedded in a larger shared-surface app must not open a second GL
165
+ // tenancy, and mounts no buffer of its own.
166
+ const outerSurface = useSurfaceOptional();
167
+ const outerCanvas = useSurfaceCanvas();
168
+ const [ownCanvas, setOwnCanvas] = useState<HTMLCanvasElement | null>(null);
169
+ const ownCanvasRef = useRef<HTMLCanvasElement | null>(null);
170
+ const bufferRef = useRef({ w: 0, h: 0 });
171
+ const surfaceRef = useRef<ReturnType<typeof useTiledSurface> | null>(null);
172
+
173
+ // Sizing the buffer clears all of it, so every tile has to repaint — not
174
+ // only the one whose move triggered the measurement.
175
+ const onFrame = useCallback((frame: { size: { width: number; height: number }; dpr: number }) => {
176
+ const c = ownCanvasRef.current;
177
+ if (!c) return;
178
+ const w = Math.round(frame.size.width * frame.dpr);
179
+ const h = Math.round(frame.size.height * frame.dpr);
180
+ if (bufferRef.current.w === w && bufferRef.current.h === h) return;
181
+ bufferRef.current = { w, h };
182
+ c.width = w;
183
+ c.height = h;
184
+ c.style.width = `${frame.size.width}px`;
185
+ c.style.height = `${frame.size.height}px`;
186
+ surfaceRef.current?.invalidateAll();
187
+ }, []);
188
+
189
+ const ownSurface = useTiledSurface({ onFrame });
190
+ surfaceRef.current = ownSurface;
191
+ const surface = outerSurface ?? ownSurface;
192
+ const surfaceCanvas = outerSurface ? outerCanvas : ownCanvas;
193
+
194
+ const workspacePanels = useMemo<PanelDescriptor[]>(
195
+ () =>
196
+ Object.entries(undockedPanels).map(([key, panel]) => ({
197
+ key,
198
+ title: panel.sectionId,
199
+ as: panel.as,
200
+ })),
201
+ [undockedPanels],
202
+ );
140
203
  const resolvedMode = useResolvedMode(modeValue);
141
204
 
142
205
  useEffect(() => {
@@ -232,21 +295,43 @@ export function Lab({
232
295
  </>
233
296
  }
234
297
  >
235
- <div className="lk-lab__body">
236
- {tools ? <LabPalette tools={tools} /> : null}
237
- <Workspace
238
- ids={trials.map((w) => w.id)}
239
- resizable
240
- reorderable
241
- onReorder={(ids) => contextValue.reorderTrials(ids)}
242
- layout={layout as TrialLayout}
243
- onLayoutChange={(next) => store.getState().setLayout(next)}
244
- >
245
- {trials.map((w) => (
246
- <Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
247
- ))}
248
- </Workspace>
249
- </div>
298
+ <PanelHostContext.Provider value={panelHostsRef.current}>
299
+ <SurfaceContext.Provider value={surface}>
300
+ <SurfaceCanvasContext.Provider value={surfaceCanvas}>
301
+ <div
302
+ className="lk-lab__body"
303
+ ref={outerSurface ? undefined : ownSurface.containerRef}
304
+ >
305
+ {outerSurface ? null : (
306
+ // Above the trials and inert: the marks a tile paints have
307
+ // to sit over the instrument's own DOM, and nothing on this
308
+ // buffer takes input — each tile has its own input box.
309
+ <canvas
310
+ className="lk-lab__surface"
311
+ ref={(el) => {
312
+ ownCanvasRef.current = el;
313
+ setOwnCanvas(el);
314
+ }}
315
+ />
316
+ )}
317
+ {tools ? <LabPalette tools={tools} /> : null}
318
+ <Workspace
319
+ panels={workspacePanels}
320
+ ids={trials.map((w) => w.id)}
321
+ resizable
322
+ reorderable
323
+ onReorder={(ids) => contextValue.reorderTrials(ids)}
324
+ layout={layout as TrialLayout}
325
+ onLayoutChange={(next) => store.getState().setLayout(next)}
326
+ >
327
+ {trials.map((w) => (
328
+ <Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
329
+ ))}
330
+ </Workspace>
331
+ </div>
332
+ </SurfaceCanvasContext.Provider>
333
+ </SurfaceContext.Provider>
334
+ </PanelHostContext.Provider>
250
335
  </LabShell>
251
336
  </ThemeProvider>
252
337
  </LabContext.Provider>
@@ -0,0 +1,85 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { TrialContribution } from '../chrome/types';
5
+ import type { Instrument } from '../instrument/types';
6
+ import { Lab } from './Lab';
7
+
8
+ const bare: Instrument = {
9
+ name: 'Bare',
10
+ defaultConfig: () => ({}),
11
+ initialState: () => ({}),
12
+ render: () => null,
13
+ };
14
+
15
+ const notes: TrialContribution = {
16
+ id: 'notes',
17
+ region: 'sidebar',
18
+ item: { title: 'Notes', body: <p data-testid="notes-body">jot</p> },
19
+ };
20
+
21
+ const floater: TrialContribution = {
22
+ id: 'floater',
23
+ region: 'sidebar',
24
+ item: {
25
+ title: 'Floater',
26
+ undockAs: 'floating',
27
+ body: <p data-testid="floater-body">hover</p>,
28
+ },
29
+ };
30
+
31
+ const pinned: TrialContribution = {
32
+ id: 'pinned',
33
+ region: 'sidebar',
34
+ item: { title: 'Pinned', undockable: false, body: <p>stay</p> },
35
+ };
36
+
37
+ function renderLab() {
38
+ return render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[notes]} />);
39
+ }
40
+
41
+ describe('undocking a sidebar section', () => {
42
+ it('starts docked inside the trial sidebar', () => {
43
+ const { container } = renderLab();
44
+ const sidebar = container.querySelector('.lk-trial__sidebar');
45
+ expect(sidebar).toContainElement(screen.getByTestId('notes-body'));
46
+ });
47
+
48
+ it('moves the section out of the sidebar and into a workspace panel', async () => {
49
+ const { container } = renderLab();
50
+ await userEvent.click(screen.getByRole('button', { name: 'Undock Notes' }));
51
+
52
+ expect(container.querySelector('.lk-trial__sidebar')).not.toContainElement(
53
+ screen.queryByTestId('notes-body'),
54
+ );
55
+ const panel = container.querySelector('.lk-panel-tile');
56
+ expect(panel).toContainElement(screen.getByTestId('notes-body'));
57
+ });
58
+
59
+ it('docks it back', async () => {
60
+ const { container } = renderLab();
61
+ await userEvent.click(screen.getByRole('button', { name: 'Undock Notes' }));
62
+ await userEvent.click(screen.getByRole('button', { name: 'Dock Notes' }));
63
+
64
+ expect(container.querySelector('.lk-panel-tile')).toBeNull();
65
+ expect(container.querySelector('.lk-trial__sidebar')).toContainElement(
66
+ screen.getByTestId('notes-body'),
67
+ );
68
+ });
69
+
70
+ it('sends a section declaring undockAs floating to the floating layer', async () => {
71
+ const { container } = render(
72
+ <Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[floater]} />,
73
+ );
74
+ await userEvent.click(screen.getByRole('button', { name: 'Undock Floater' }));
75
+
76
+ const floating = container.querySelector('.lk-workspace__floating');
77
+ expect(floating).toContainElement(screen.getByTestId('floater-body'));
78
+ expect(container.querySelector('.lk-panel-tile--floating')).not.toBeNull();
79
+ });
80
+
81
+ it('offers no tear-out control on a section that opts out', () => {
82
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[pinned]} />);
83
+ expect(screen.queryByRole('button', { name: 'Undock Pinned' })).toBeNull();
84
+ });
85
+ });
@@ -280,6 +280,26 @@ export const AllChrome: Story = {
280
280
  },
281
281
  };
282
282
 
283
+ /** Undocking, in both targets. `Notes` declares `undockAs: 'floating'`, so its
284
+ * tear-out control sends it to the layer above the grid; every other section
285
+ * defaults to a tile and joins the tiling as a peer of the trials. */
286
+ export const UndockablePanels: Story = {
287
+ args: {
288
+ ...AllChrome.args,
289
+ chrome: [
290
+ {
291
+ id: 'notes',
292
+ region: 'sidebar',
293
+ item: {
294
+ title: 'Notes',
295
+ undockAs: 'floating',
296
+ body: <p className="lk-run-tag">Undock me — I float.</p>,
297
+ },
298
+ },
299
+ ],
300
+ },
301
+ };
302
+
283
303
  /** The chrome the other story cannot show at the same time: one instrument, so
284
304
  * the header is an Add trial button; one trial, so Close is disabled; no
285
305
  * config schema, canvas, undo or job, so the sidebar falls back to its
@@ -48,12 +48,23 @@
48
48
  display: flex;
49
49
  height: 100%;
50
50
  min-height: 0;
51
+ position: relative;
51
52
 
52
53
  > .lk-workspace {
53
54
  min-width: 0;
54
55
  }
55
56
  }
56
57
 
58
+ // The lab's one shared drawing buffer. Over the trials, because a tile's marks
59
+ // annotate the instrument's own DOM; under the viewport chrome, whose z-overlay
60
+ // outranks it; and inert, because every tile takes input from its own box.
61
+ .lk-lab__surface {
62
+ position: absolute;
63
+ inset: 0;
64
+ z-index: 1;
65
+ pointer-events: none;
66
+ }
67
+
57
68
  .lk-shell-footer {
58
69
  border-top: 1px solid var(--wzl-border);
59
70
  padding: var(--wzl-space-xs) var(--wzl-space-md);