@weasel-js/labkit 1.3.0-pre.0 → 1.4.0-pre.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 (259) hide show
  1. package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
  2. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
  3. package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
  4. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
  5. package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
  6. package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
  7. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
  8. package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
  9. package/dist/_dts/{types-AHlQxBNN.d.ts → types-D6s4b7if.d.ts} +2 -2
  10. package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
  11. package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
  12. package/dist/canvas/index.d.ts +43 -25
  13. package/dist/canvas/index.js +5 -3
  14. package/dist/chrome/index.d.ts +9 -6
  15. package/dist/chrome/index.js +7 -6
  16. package/dist/chunk-2AYGEN57.js +32 -0
  17. package/dist/chunk-2AYGEN57.js.map +1 -0
  18. package/dist/{chunk-RVNN4CHQ.js → chunk-3P5TTTA7.js} +16 -8
  19. package/dist/chunk-3P5TTTA7.js.map +1 -0
  20. package/dist/{chunk-NS54R52R.js → chunk-3ZELRRBV.js} +45 -40
  21. package/dist/chunk-3ZELRRBV.js.map +1 -0
  22. package/dist/{chunk-HFDVGF4X.js → chunk-4EY67BKJ.js} +32 -7
  23. package/dist/chunk-4EY67BKJ.js.map +1 -0
  24. package/dist/chunk-5TCEVMPI.js +140 -0
  25. package/dist/chunk-5TCEVMPI.js.map +1 -0
  26. package/dist/{chunk-O7NVSCLN.js → chunk-6JMU56CU.js} +133 -134
  27. package/dist/chunk-6JMU56CU.js.map +1 -0
  28. package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
  29. package/dist/chunk-CQLQPQ4P.js.map +1 -0
  30. package/dist/chunk-CTRKTLYZ.js +33 -0
  31. package/dist/chunk-CTRKTLYZ.js.map +1 -0
  32. package/dist/chunk-EQIB2PGC.js +458 -0
  33. package/dist/chunk-EQIB2PGC.js.map +1 -0
  34. package/dist/chunk-HEORBLVT.js +7128 -0
  35. package/dist/chunk-HEORBLVT.js.map +1 -0
  36. package/dist/{chunk-V7PRSIRQ.js → chunk-JDDIZSLL.js} +4 -4
  37. package/dist/chunk-JDDIZSLL.js.map +1 -0
  38. package/dist/chunk-LITE5YFB.js +669 -0
  39. package/dist/chunk-LITE5YFB.js.map +1 -0
  40. package/dist/{chunk-Z6HJ5FEM.js → chunk-ROQDRXKW.js} +732 -315
  41. package/dist/chunk-ROQDRXKW.js.map +1 -0
  42. package/dist/chunk-SBXNQW4G.js +3 -0
  43. package/dist/chunk-SBXNQW4G.js.map +1 -0
  44. package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
  45. package/dist/chunk-XJ6N32QP.js.map +1 -0
  46. package/dist/chunk-YQJNT4XQ.js +293 -0
  47. package/dist/chunk-YQJNT4XQ.js.map +1 -0
  48. package/dist/controls/index.d.ts +4 -28
  49. package/dist/controls/index.js +3 -3
  50. package/dist/dragdrop/index.d.ts +50 -5
  51. package/dist/dragdrop/index.js +3 -1
  52. package/dist/index.d.ts +660 -302
  53. package/dist/index.js +1793 -587
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +6 -5
  56. package/dist/layers/index.js +4 -2
  57. package/dist/loupe/index.d.ts +187 -0
  58. package/dist/loupe/index.js +7 -0
  59. package/dist/loupe/index.js.map +1 -0
  60. package/dist/passthrough/weasel-canvas.d.ts +3 -1
  61. package/dist/passthrough/weasel-canvas.js +1 -1
  62. package/dist/passthrough/weasel-ui.d.ts +151 -91
  63. package/dist/passthrough/weasel-ui.js +3 -2
  64. package/dist/primitives/index.d.ts +3 -1
  65. package/dist/primitives/index.js +6 -5
  66. package/dist/state/index.d.ts +3 -3
  67. package/dist/state/index.js +2 -2
  68. package/dist/styles.css +229 -703
  69. package/dist/surface/index.d.ts +18 -2
  70. package/dist/surface/index.js +2 -2
  71. package/dist/ui/layers/index.d.ts +8 -2
  72. package/dist/ui/layers/index.js +2 -4
  73. package/dist/undo/index.d.ts +5 -4
  74. package/package.json +14 -7
  75. package/src/annotations/AnnotationOverlay.tsx +205 -0
  76. package/src/annotations/AnnotationTargets.tsx +37 -0
  77. package/src/annotations/Annotations.less +129 -0
  78. package/src/annotations/Annotations.meaning.test.tsx +111 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +142 -0
  80. package/src/annotations/AnnotationsContext.ts +49 -0
  81. package/src/annotations/ExportMenu.test.tsx +86 -0
  82. package/src/annotations/ExportMenu.tsx +182 -0
  83. package/src/annotations/MarkList.tsx +75 -0
  84. package/src/annotations/capture.test.ts +140 -0
  85. package/src/annotations/capture.ts +237 -0
  86. package/src/annotations/drawOne.test.ts +56 -0
  87. package/src/annotations/drawOne.ts +47 -0
  88. package/src/annotations/frac.test.ts +59 -0
  89. package/src/annotations/frac.ts +66 -0
  90. package/src/annotations/history.test.ts +82 -0
  91. package/src/annotations/history.ts +118 -0
  92. package/src/annotations/index.ts +37 -0
  93. package/src/annotations/paint.test.ts +125 -0
  94. package/src/annotations/paint.ts +130 -0
  95. package/src/annotations/staleness.test.ts +56 -0
  96. package/src/annotations/staleness.ts +41 -0
  97. package/src/annotations/store.test.ts +175 -0
  98. package/src/annotations/store.ts +317 -0
  99. package/src/annotations/svgNodes.test.ts +77 -0
  100. package/src/annotations/svgNodes.ts +74 -0
  101. package/src/annotations/toolMap.test.ts +36 -0
  102. package/src/annotations/toolMap.ts +54 -0
  103. package/src/annotations/types.ts +222 -0
  104. package/src/annotations/view.test.ts +37 -0
  105. package/src/annotations/view.ts +40 -0
  106. package/src/canvas/AGENTS.md +55 -5
  107. package/src/canvas/CanvasStack.test.tsx +27 -1
  108. package/src/canvas/CanvasStack.tsx +29 -4
  109. package/src/canvas/CanvasStackContext.ts +23 -4
  110. package/src/canvas/camera.test.ts +78 -0
  111. package/src/canvas/camera.ts +62 -0
  112. package/src/canvas/canvasCoords.test.ts +26 -0
  113. package/src/canvas/canvasCoords.ts +20 -7
  114. package/src/canvas/index.ts +5 -1
  115. package/src/canvas/useLayerScheduler.ts +7 -3
  116. package/src/canvas/usePanZoom.test.ts +36 -5
  117. package/src/canvas/usePanZoom.ts +20 -15
  118. package/src/canvas/worldSpec.test.ts +63 -0
  119. package/src/canvas/worldSpec.ts +48 -0
  120. package/src/chrome/builtins.test.ts +31 -1
  121. package/src/chrome/builtins.tsx +63 -19
  122. package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
  123. package/src/chrome/regions/PaletteRegion.tsx +4 -0
  124. package/src/chrome/regions/SidebarRegion.tsx +37 -13
  125. package/src/chrome/regions/ToolbarRegion.tsx +2 -1
  126. package/src/chrome/regions/ViewportRegion.tsx +9 -1
  127. package/src/chrome/regions/regions.test.tsx +6 -1
  128. package/src/chrome/types.ts +20 -0
  129. package/src/config/builder.test.ts +7 -1
  130. package/src/config/builder.ts +1 -5
  131. package/src/config/resolve.test.ts +1 -2
  132. package/src/controls/ControlPanel.stories.tsx +41 -2
  133. package/src/controls/ControlPanel.test.tsx +69 -9
  134. package/src/controls/ControlPanel.tsx +106 -25
  135. package/src/dragdrop/DragDropRuntime.tsx +8 -2
  136. package/src/dragdrop/dragDrop.test.tsx +29 -2
  137. package/src/dragdrop/index.ts +11 -0
  138. package/src/index.test.ts +22 -0
  139. package/src/index.ts +136 -4
  140. package/src/instrument/SineWave.smoke.test.tsx +1 -0
  141. package/src/instrument/types.ts +24 -1
  142. package/src/lab/Lab.surface.test.tsx +162 -0
  143. package/src/lab/Lab.tsx +102 -17
  144. package/src/lab/Lab.undock.test.tsx +85 -0
  145. package/src/lab/LabFullChrome.stories.tsx +20 -0
  146. package/src/lab/LabShell.less +11 -0
  147. package/src/lab/Workspace.less +73 -0
  148. package/src/lab/Workspace.surface.test.tsx +2 -0
  149. package/src/lab/Workspace.tsx +97 -8
  150. package/src/lab/index.ts +1 -1
  151. package/src/lab/panelHost.ts +37 -0
  152. package/src/layers/AGENTS.md +1 -1
  153. package/src/layers/LayerList.tsx +1 -1
  154. package/src/loupe/AGENTS.md +49 -0
  155. package/src/loupe/CanvasLoupe.tsx +83 -0
  156. package/src/loupe/DomLoupe.tsx +62 -0
  157. package/src/loupe/Loupe.less +38 -0
  158. package/src/loupe/LoupeBubble.tsx +32 -0
  159. package/src/loupe/TrialLoupe.tsx +93 -0
  160. package/src/loupe/canvasLens.test.ts +205 -0
  161. package/src/loupe/canvasLens.ts +141 -0
  162. package/src/loupe/index.ts +19 -0
  163. package/src/loupe/types.test.ts +29 -0
  164. package/src/loupe/types.ts +90 -0
  165. package/src/loupe/useHostSize.ts +30 -0
  166. package/src/loupe/useLoupe.test.tsx +197 -0
  167. package/src/loupe/useLoupe.ts +189 -0
  168. package/src/passthrough/weasel-ui.ts +25 -17
  169. package/src/primitives/FloatingPanel.test.tsx +76 -1
  170. package/src/primitives/FloatingPanel.tsx +19 -3
  171. package/src/primitives/ScaleIndicator.test.tsx +7 -2
  172. package/src/primitives/Sidebar.less +18 -0
  173. package/src/primitives/Toolbar.less +8 -0
  174. package/src/primitives/Toolbar.tsx +5 -0
  175. package/src/primitives/useRovingTabIndex.test.ts +21 -0
  176. package/src/primitives/useRovingTabIndex.ts +26 -17
  177. package/src/state/document.test.ts +23 -0
  178. package/src/state/document.ts +14 -2
  179. package/src/state/index.ts +2 -0
  180. package/src/state/store.test.ts +24 -0
  181. package/src/state/store.ts +35 -1
  182. package/src/state/types.ts +11 -0
  183. package/src/state/undock.test.ts +40 -0
  184. package/src/state/undock.ts +39 -0
  185. package/src/styles.less +2 -4
  186. package/src/surface/SurfaceContext.ts +4 -0
  187. package/src/surface/index.ts +8 -3
  188. package/src/surface/useSurfaceTile.test.tsx +2 -0
  189. package/src/surface/useSurfaceTile.ts +7 -1
  190. package/src/surface/useTiledSurface.test.tsx +58 -0
  191. package/src/surface/useTiledSurface.ts +38 -3
  192. package/src/theme/Interstellar.stories.tsx +1 -1
  193. package/src/trial/Trial.annotations.persist.test.tsx +166 -0
  194. package/src/trial/Trial.annotations.test.tsx +121 -0
  195. package/src/trial/Trial.canvas.test.tsx +76 -1
  196. package/src/trial/Trial.config.test.tsx +3 -8
  197. package/src/trial/Trial.less +19 -0
  198. package/src/trial/Trial.loupe.test.tsx +128 -0
  199. package/src/trial/Trial.stories.tsx +1 -0
  200. package/src/trial/Trial.test.tsx +12 -0
  201. package/src/trial/Trial.trialId.test.tsx +36 -0
  202. package/src/trial/Trial.tsx +215 -40
  203. package/src/trial/TrialChrome.tsx +35 -0
  204. package/src/trial/UndockedSections.tsx +65 -0
  205. package/src/trial/trialOps.test.ts +21 -0
  206. package/src/trial/trialOps.ts +5 -3
  207. package/src/ui/layers/index.ts +5 -1
  208. package/dist/_dts/index-DgcNdEdh.d.ts +0 -314
  209. package/dist/chunk-4TMMVIDM.js.map +0 -1
  210. package/dist/chunk-CPUJ3QXL.js.map +0 -1
  211. package/dist/chunk-HFDVGF4X.js.map +0 -1
  212. package/dist/chunk-NS54R52R.js.map +0 -1
  213. package/dist/chunk-O2BULFHX.js +0 -152
  214. package/dist/chunk-O2BULFHX.js.map +0 -1
  215. package/dist/chunk-O7NVSCLN.js.map +0 -1
  216. package/dist/chunk-PO6L3NSH.js +0 -366
  217. package/dist/chunk-PO6L3NSH.js.map +0 -1
  218. package/dist/chunk-PQJ5B2U2.js +0 -27
  219. package/dist/chunk-PQJ5B2U2.js.map +0 -1
  220. package/dist/chunk-RVNN4CHQ.js.map +0 -1
  221. package/dist/chunk-TLGRYALP.js +0 -6151
  222. package/dist/chunk-TLGRYALP.js.map +0 -1
  223. package/dist/chunk-V7PRSIRQ.js.map +0 -1
  224. package/dist/chunk-Z6HJ5FEM.js.map +0 -1
  225. package/dist/chunk-ZZAYVX4X.js +0 -505
  226. package/dist/chunk-ZZAYVX4X.js.map +0 -1
  227. package/src/primitives/DragHandleGlyph.tsx +0 -29
  228. package/src/ui/format.test.ts +0 -33
  229. package/src/ui/format.ts +0 -30
  230. package/src/ui/layers/LayerStack.less +0 -143
  231. package/src/ui/layers/LayerStack.stories.tsx +0 -45
  232. package/src/ui/layers/LayerStack.test.tsx +0 -170
  233. package/src/ui/layers/LayerStack.tsx +0 -208
  234. package/src/ui/properties/CheckboxRow.stories.tsx +0 -27
  235. package/src/ui/properties/ColorRow.stories.tsx +0 -106
  236. package/src/ui/properties/CurveField.less +0 -48
  237. package/src/ui/properties/CurveField.stories.tsx +0 -20
  238. package/src/ui/properties/CurveField.test.tsx +0 -127
  239. package/src/ui/properties/CurveField.tsx +0 -184
  240. package/src/ui/properties/EffectCard.tsx +0 -324
  241. package/src/ui/properties/Gallery.stories.tsx +0 -178
  242. package/src/ui/properties/NumberRow.stories.tsx +0 -65
  243. package/src/ui/properties/PropertyGroup.less +0 -50
  244. package/src/ui/properties/PropertyGroup.stories.tsx +0 -34
  245. package/src/ui/properties/PropertyGroup.test.tsx +0 -33
  246. package/src/ui/properties/PropertyGroup.tsx +0 -45
  247. package/src/ui/properties/PropertyList.stories.tsx +0 -83
  248. package/src/ui/properties/PropertyPanel.less +0 -606
  249. package/src/ui/properties/PropertyPanel.stories.tsx +0 -190
  250. package/src/ui/properties/PropertyPanel.test.tsx +0 -193
  251. package/src/ui/properties/PropertyPanel.tsx +0 -469
  252. package/src/ui/properties/PropertyRow.stories.tsx +0 -53
  253. package/src/ui/properties/SelectRow.stories.tsx +0 -58
  254. package/src/ui/properties/SliderRow.stories.tsx +0 -74
  255. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +0 -398
  256. package/src/ui/properties/TextRow.stories.tsx +0 -62
  257. package/src/ui/properties/ToggleRow.stories.tsx +0 -57
  258. package/src/ui/properties/index.ts +0 -39
  259. package/src/ui/properties/storyLayouts.tsx +0 -36
@@ -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);
@@ -9,3 +9,76 @@
9
9
  height: 100%;
10
10
  min-height: 0;
11
11
  }
12
+
13
+ // An undocked sidebar section. A tile version is placed by the grid like any
14
+ // trial; the floating version sits above the grid in its own layer.
15
+ .lk-panel-tile {
16
+ display: flex;
17
+ flex-direction: column;
18
+ height: 100%;
19
+ overflow: hidden;
20
+ border: var(--wzl-border-w) solid var(--wzl-line-subtle);
21
+ border-radius: var(--wzl-radius-sm);
22
+ background: var(--wzl-surface);
23
+ }
24
+
25
+ .lk-panel-tile__titlebar {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: var(--wzl-space-sm);
29
+ flex-shrink: 0;
30
+ padding: 0 var(--wzl-space-sm);
31
+ border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
32
+ }
33
+
34
+ .lk-panel-tile__title {
35
+ flex: 1;
36
+ color: var(--wzl-fg-muted);
37
+ font-family: var(--wzl-font-display);
38
+ font-size: var(--wzl-font-size-sm);
39
+ letter-spacing: 0.08em;
40
+ text-transform: uppercase;
41
+ }
42
+
43
+ .lk-panel-tile__dock {
44
+ border: 0;
45
+ background: transparent;
46
+ color: var(--wzl-fg-subtle);
47
+ cursor: pointer;
48
+
49
+ &:hover {
50
+ color: var(--wzl-fg);
51
+ }
52
+ }
53
+
54
+ .lk-panel-tile__body {
55
+ flex: 1;
56
+ min-height: 0;
57
+ overflow: auto;
58
+ }
59
+
60
+ .lk-workspace-host {
61
+ position: relative;
62
+ width: 100%;
63
+ height: 100%;
64
+ }
65
+
66
+ .lk-workspace__floating {
67
+ position: absolute;
68
+ inset: 0;
69
+ pointer-events: none;
70
+
71
+ > * {
72
+ pointer-events: auto;
73
+ }
74
+ }
75
+
76
+ .lk-panel-tile--floating {
77
+ position: absolute;
78
+ top: var(--wzl-space-md);
79
+ right: var(--wzl-space-md);
80
+ width: 260px;
81
+ height: auto;
82
+ max-height: 60%;
83
+ box-shadow: var(--wzl-shadow-panel, 0 2px 8px rgb(0 0 0 / 0.3));
84
+ }
@@ -10,7 +10,9 @@ function fakeHandle(): SurfaceHandle {
10
10
  invalidateAll: vi.fn(),
11
11
  invalidateRects: vi.fn(),
12
12
  registerTile: vi.fn(),
13
+ registerPainter: vi.fn(() => () => {}),
13
14
  containerRef: vi.fn(),
15
+ getContainer: vi.fn(() => null),
14
16
  };
15
17
  }
16
18