@weasel-js/labkit 1.7.3 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +24 -0
  2. package/dist/_dts/{CanvasStackContext-Cjy6I-Iv.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
  3. package/dist/_dts/{Stage-D6t7o0pF.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
  4. package/dist/_dts/{index-DCMA4Fy1.d.ts → index-8geQNQet.d.ts} +84 -12
  5. package/dist/_dts/{index-BTmaRy6a.d.ts → index-DRi3V5sN.d.ts} +6 -4
  6. package/dist/_dts/{types-BUBSIZuV.d.ts → types-DUfQ0isU.d.ts} +5 -1
  7. package/dist/_dts/{useTrialState-Bh8bzFgE.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
  8. package/dist/canvas/index.d.ts +5 -5
  9. package/dist/canvas/index.js +2 -2
  10. package/dist/chrome/index.d.ts +3 -3
  11. package/dist/chrome/index.js +7 -7
  12. package/dist/{chunk-HBGH344L.js → chunk-4RCVWX4M.js} +55 -18
  13. package/dist/chunk-4RCVWX4M.js.map +1 -0
  14. package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
  15. package/dist/chunk-6IIKLXBZ.js.map +1 -0
  16. package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
  17. package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
  18. package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
  19. package/dist/chunk-7ALJOVGP.js.map +1 -0
  20. package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
  21. package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
  22. package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
  23. package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
  24. package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
  25. package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
  26. package/dist/{chunk-LPGBC63T.js → chunk-KHB3KRRQ.js} +7 -7
  27. package/dist/chunk-KHB3KRRQ.js.map +1 -0
  28. package/dist/{chunk-IBVRMHW5.js → chunk-NHIO2O3O.js} +31 -30
  29. package/dist/chunk-NHIO2O3O.js.map +1 -0
  30. package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
  31. package/dist/chunk-QMAY74TP.js.map +1 -0
  32. package/dist/{chunk-4X44DMRS.js → chunk-RN7T7OBO.js} +46 -24
  33. package/dist/chunk-RN7T7OBO.js.map +1 -0
  34. package/dist/{chunk-4TJ34FXA.js → chunk-RXO5UEGT.js} +5 -12
  35. package/dist/chunk-RXO5UEGT.js.map +1 -0
  36. package/dist/{chunk-E6NARGU7.js → chunk-SQX35X5V.js} +3 -3
  37. package/dist/{chunk-E6NARGU7.js.map → chunk-SQX35X5V.js.map} +1 -1
  38. package/dist/{chunk-YCFKW5RX.js → chunk-T5OEUNCB.js} +16 -2
  39. package/dist/chunk-T5OEUNCB.js.map +1 -0
  40. package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
  41. package/dist/chunk-UX66USTN.js.map +1 -0
  42. package/dist/{chunk-IRMPZIYU.js → chunk-V4SMGEYY.js} +6732 -6507
  43. package/dist/chunk-V4SMGEYY.js.map +1 -0
  44. package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
  45. package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
  46. package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
  47. package/dist/chunk-XWR2P6MR.js.map +1 -0
  48. package/dist/{chunk-XSJJTXNA.js → chunk-YZA73U4A.js} +19 -19
  49. package/dist/chunk-YZA73U4A.js.map +1 -0
  50. package/dist/config/index.d.ts +12 -5
  51. package/dist/config/index.js +2 -2
  52. package/dist/controls/index.d.ts +2 -2
  53. package/dist/controls/index.js +5 -5
  54. package/dist/dragdrop/index.d.ts +2 -2
  55. package/dist/dragdrop/index.js +2 -2
  56. package/dist/index.d.ts +158 -16
  57. package/dist/index.js +1036 -415
  58. package/dist/index.js.map +1 -1
  59. package/dist/layers/index.d.ts +2 -2
  60. package/dist/layers/index.js +3 -3
  61. package/dist/loupe/index.d.ts +176 -28
  62. package/dist/loupe/index.js +547 -141
  63. package/dist/loupe/index.js.map +1 -1
  64. package/dist/overview/index.d.ts +1 -1
  65. package/dist/overview/index.js +5 -5
  66. package/dist/passthrough/weasel-ui.js +3 -3
  67. package/dist/primitives/index.d.ts +4 -19
  68. package/dist/primitives/index.js +7 -7
  69. package/dist/state/index.d.ts +6 -8
  70. package/dist/state/index.js +5 -5
  71. package/dist/styles.css +181 -44
  72. package/dist/surface/index.d.ts +20 -1
  73. package/dist/surface/index.js +2 -2
  74. package/dist/undo/index.d.ts +19 -8
  75. package/dist/undo/index.js +1 -1
  76. package/package.json +11 -11
  77. package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
  78. package/src/annotations/AnnotationOverlay.tsx +10 -1
  79. package/src/annotations/Annotations.less +2 -0
  80. package/src/annotations/Annotations.overlay.test.tsx +29 -0
  81. package/src/canvas/CanvasStack.test.tsx +35 -0
  82. package/src/canvas/CanvasStack.tsx +12 -1
  83. package/src/canvas/useLayerScheduler.ts +14 -0
  84. package/src/chrome/builtins.test.ts +14 -0
  85. package/src/chrome/builtins.tsx +17 -2
  86. package/src/chrome/regions/StatusRegion.tsx +20 -14
  87. package/src/chrome/regions/regions.test.tsx +28 -0
  88. package/src/chrome/types.ts +8 -0
  89. package/src/clock/clock.browser.test.less +5 -0
  90. package/src/clock/clock.browser.test.tsx +77 -0
  91. package/src/clock/clock.integration.test.tsx +95 -0
  92. package/src/clock/clockRegistry.test.ts +33 -0
  93. package/src/clock/clockRegistry.ts +46 -0
  94. package/src/clock/hooks.ts +44 -0
  95. package/src/clock/index.ts +2 -0
  96. package/src/clock/trialClock.test.ts +265 -0
  97. package/src/clock/trialClock.ts +245 -0
  98. package/src/clock/useClockLoop.ts +45 -0
  99. package/src/config/builder.ts +11 -1
  100. package/src/config/resolve.test.ts +12 -0
  101. package/src/config/resolve.ts +5 -0
  102. package/src/config/types.ts +5 -1
  103. package/src/controls/ControlMatrix.test.tsx +10 -0
  104. package/src/controls/ControlMatrix.tsx +110 -58
  105. package/src/controls/ControlPanel.test.tsx +30 -0
  106. package/src/index.ts +3 -2
  107. package/src/instrument/types.ts +21 -1
  108. package/src/lab/Lab.chrome.test.tsx +8 -1
  109. package/src/lab/Lab.presentation.test.tsx +113 -0
  110. package/src/lab/Lab.surface.test.tsx +20 -9
  111. package/src/lab/Lab.tsx +132 -257
  112. package/src/lab/LabLightbox.browser.test.less +17 -0
  113. package/src/lab/LabLightbox.browser.test.tsx +216 -0
  114. package/src/lab/LabShell.less +10 -3
  115. package/src/lab/LabSurface.tsx +129 -0
  116. package/src/lab/Workspace.stories.tsx +18 -0
  117. package/src/lab/Workspace.surface.test.tsx +4 -0
  118. package/src/lab/Workspace.test.tsx +37 -1
  119. package/src/lab/Workspace.tsx +34 -6
  120. package/src/lab/index.ts +2 -0
  121. package/src/lab/nebula.ts +30 -0
  122. package/src/lab/openLab.test.ts +81 -0
  123. package/src/lab/openLab.ts +119 -0
  124. package/src/lab/presentation.browser.test.less +5 -0
  125. package/src/lab/presentation.browser.test.tsx +119 -0
  126. package/src/lab/presentation.less +64 -0
  127. package/src/lab/presentation.tsx +47 -0
  128. package/src/lab/useFocusPick.ts +43 -0
  129. package/src/lightbox/Lightbox.browser.test.less +14 -0
  130. package/src/lightbox/Lightbox.browser.test.tsx +113 -0
  131. package/src/lightbox/Lightbox.less +128 -0
  132. package/src/lightbox/Lightbox.test.tsx +195 -0
  133. package/src/lightbox/Lightbox.tsx +205 -0
  134. package/src/lightbox/LightboxContext.ts +38 -0
  135. package/src/lightbox/LightboxLayers.tsx +40 -0
  136. package/src/lightbox/index.ts +8 -0
  137. package/src/lightbox/modal.ts +94 -0
  138. package/src/loupe/AGENTS.md +40 -2
  139. package/src/loupe/CanvasLoupe.tsx +5 -4
  140. package/src/loupe/DomLoupe.tsx +7 -5
  141. package/src/loupe/Loupe.less +15 -0
  142. package/src/loupe/LoupeBubble.tsx +35 -13
  143. package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
  144. package/src/loupe/SourceLoupe.tsx +60 -0
  145. package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
  146. package/src/loupe/TrialLoupe.tsx +126 -28
  147. package/src/loupe/canvasLens.ts +20 -17
  148. package/src/loupe/index.ts +26 -2
  149. package/src/loupe/sourceLens.test.ts +65 -0
  150. package/src/loupe/sourceLens.ts +97 -0
  151. package/src/loupe/types.test.ts +6 -0
  152. package/src/loupe/types.ts +109 -7
  153. package/src/loupe/useLoupe.test.tsx +188 -3
  154. package/src/loupe/useLoupe.ts +106 -11
  155. package/src/primitives/index.ts +2 -2
  156. package/src/specimen/Specimen.tsx +0 -7
  157. package/src/state/helpers.test.ts +4 -11
  158. package/src/state/helpers.ts +2 -8
  159. package/src/state/index.ts +0 -2
  160. package/src/state/openLabStore.test.ts +10 -8
  161. package/src/state/openLabStore.ts +1 -1
  162. package/src/state/store.test.ts +2 -2
  163. package/src/state/store.ts +15 -16
  164. package/src/state/types.ts +9 -9
  165. package/src/styles.less +2 -1
  166. package/src/surface/AGENTS.md +13 -0
  167. package/src/surface/index.ts +1 -0
  168. package/src/surface/useSurfaceTile.test.tsx +4 -0
  169. package/src/surface/useSurfaceTile.ts +17 -1
  170. package/src/surface/useTiledSurface.test.tsx +65 -0
  171. package/src/surface/useTiledSurface.ts +68 -1
  172. package/src/theme/interstellar.theme.json +0 -14
  173. package/src/trial/Trial.stories.tsx +0 -1
  174. package/src/trial/Trial.test.tsx +40 -3
  175. package/src/trial/Trial.tsx +87 -43
  176. package/src/trial/TrialChrome.tsx +20 -2
  177. package/src/trial/trialOps.test.ts +1 -2
  178. package/src/trial/trialOps.ts +2 -3
  179. package/src/undo/stateOp.ts +21 -0
  180. package/src/undo/undoStack.ts +18 -6
  181. package/dist/chunk-4TJ34FXA.js.map +0 -1
  182. package/dist/chunk-4X44DMRS.js.map +0 -1
  183. package/dist/chunk-EESJUSYR.js.map +0 -1
  184. package/dist/chunk-HBGH344L.js.map +0 -1
  185. package/dist/chunk-IBVRMHW5.js.map +0 -1
  186. package/dist/chunk-IRMPZIYU.js.map +0 -1
  187. package/dist/chunk-LPGBC63T.js.map +0 -1
  188. package/dist/chunk-PMLZP7RT.js.map +0 -1
  189. package/dist/chunk-RL2LOLNI.js.map +0 -1
  190. package/dist/chunk-T6DLGEJB.js.map +0 -1
  191. package/dist/chunk-WIG6XHJ7.js.map +0 -1
  192. package/dist/chunk-XSJJTXNA.js.map +0 -1
  193. package/dist/chunk-YCFKW5RX.js.map +0 -1
  194. package/src/primitives/StatusBar.less +0 -34
  195. package/src/primitives/StatusBar.stories.tsx +0 -24
  196. package/src/primitives/StatusBar.test.tsx +0 -25
  197. package/src/primitives/StatusBar.tsx +0 -28
@@ -0,0 +1,216 @@
1
+ import '@weasel-js/theme/tokens.css';
2
+ import 'windease/styles.css';
3
+ import '../styles.less';
4
+ import './LabLightbox.browser.test.less';
5
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
6
+ import { useEffect } from 'react';
7
+ import { afterEach, beforeEach, expect, test } from 'vitest';
8
+ import { page, userEvent } from 'vitest/browser';
9
+ import { useAnnotations } from '../annotations/AnnotationsContext';
10
+ import { defineInstrument } from '../instrument/defineInstrument';
11
+ import { toDeviceRect } from '../surface/deviceRect';
12
+ import { useSurface, useSurfaceCanvas, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
13
+ import { Lab } from './Lab';
14
+
15
+ // Annotation marks are drawn into the lab's shared buffer and take input from
16
+ // a box the lab mounts, neither inside the trial. Whether they come along when
17
+ // the trial is expanded is stacking and hit-testing — a browser's to answer.
18
+
19
+ afterEach(cleanup);
20
+ beforeEach(async () => {
21
+ await page.viewport(1000, 700);
22
+ });
23
+
24
+ const CONTENT = { w: 200, h: 100 };
25
+ const panes = new Map<string, { current: HTMLDivElement | null }>();
26
+ const paneFor = (id: string) => {
27
+ let ref = panes.get(id);
28
+ if (!ref) {
29
+ ref = { current: null };
30
+ panes.set(id, ref);
31
+ }
32
+ return ref;
33
+ };
34
+
35
+ function Count() {
36
+ return <output data-testid="count">{useAnnotations().query().length}</output>;
37
+ }
38
+
39
+ /** Stands in for a 3D view: paints its rect green in the shared buffer
40
+ * under the trials, through the surface, as a three.js tenant would. */
41
+ function Solid() {
42
+ const attach = useSurfaceTile('solid');
43
+ const id = useTileId('solid');
44
+ const surface = useSurface();
45
+ const canvas = useSurfaceCanvas('under');
46
+ useEffect(
47
+ () =>
48
+ surface.registerPainter(id, (rect, frame) => {
49
+ const gl = canvas?.getContext('webgl2', { preserveDrawingBuffer: true });
50
+ if (!gl) return;
51
+ const v = toDeviceRect(rect, frame.size.height, frame.dpr);
52
+ const px = (n: number) => Math.round(n * frame.dpr);
53
+ gl.enable(gl.SCISSOR_TEST);
54
+ gl.scissor(px(v.x), px(v.y), px(v.w), px(v.h));
55
+ gl.clearColor(0, 1, 0, 1);
56
+ gl.clear(gl.COLOR_BUFFER_BIT);
57
+ gl.disable(gl.SCISSOR_TEST);
58
+ }),
59
+ [surface, id, canvas],
60
+ );
61
+ return <div className="lk-lightbox-lab-solid" ref={attach} />;
62
+ }
63
+
64
+ /** The under buffer's own pixel at a viewport point, read back from GL. */
65
+ function underPixelAt(x: number, y: number): [number, number, number] {
66
+ const canvas = document.querySelector('.lk-lab__layer--under canvas') as HTMLCanvasElement;
67
+ const gl = canvas.getContext('webgl2') as WebGL2RenderingContext;
68
+ const box = canvas.getBoundingClientRect();
69
+ const dpr = canvas.width / box.width;
70
+ const out = new Uint8Array(4);
71
+ const gx = Math.round((x - box.left) * dpr);
72
+ const gy = Math.round(canvas.height - (y - box.top) * dpr);
73
+ gl.readPixels(gx, gy, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out);
74
+ return [out[0] ?? 0, out[1] ?? 0, out[2] ?? 0];
75
+ }
76
+
77
+ const Marked = defineInstrument<Record<string, never>, Record<string, never>>({
78
+ name: 'Marked',
79
+ defaultConfig: () => ({}),
80
+ initialState: () => ({}),
81
+ render: (ctx) => (
82
+ <>
83
+ <div
84
+ className="lk-lightbox-lab-pane"
85
+ ref={(el) => {
86
+ paneFor(ctx.trial.id).current = el;
87
+ }}
88
+ />
89
+ <Solid />
90
+ <Count />
91
+ </>
92
+ ),
93
+ annotations: {
94
+ targets: (_s, _c, trial) => [{ id: 'pane', ref: paneFor(trial.id), content: CONTENT }],
95
+ },
96
+ });
97
+
98
+ /** A screenshot's pixels along a horizontal line, in viewport px: what the
99
+ * compositor shows, top layer included. */
100
+ async function rowAt(y: number, x0: number, x1: number): Promise<Array<[number, number, number]>> {
101
+ // With `save: false` the call resolves to the base64 string alone.
102
+ const base64 = (await page.screenshot({ base64: true, save: false })) as unknown as string;
103
+ const img = new Image();
104
+ img.src = `data:image/png;base64,${base64}`;
105
+ await img.decode();
106
+ const c = document.createElement('canvas');
107
+ c.width = img.width;
108
+ c.height = img.height;
109
+ const g = c.getContext('2d') as CanvasRenderingContext2D;
110
+ g.drawImage(img, 0, 0);
111
+ const scale = img.width / window.innerWidth;
112
+ const left = Math.round(x0 * scale);
113
+ const d = g.getImageData(left, Math.round(y * scale), Math.round((x1 - x0) * scale), 1).data;
114
+ const out: Array<[number, number, number]> = [];
115
+ for (let i = 0; i < d.length; i += 4) out.push([d[i] ?? 0, d[i + 1] ?? 0, d[i + 2] ?? 0]);
116
+ return out;
117
+ }
118
+
119
+ const BLUE = '0,0,255';
120
+
121
+ /** Drags a rectangle across the middle of `box`, in viewport px, delivered
122
+ * to whatever the browser hit-tests at the press. */
123
+ async function drawRect(box: DOMRect): Promise<void> {
124
+ const x0 = box.left + box.width * 0.3;
125
+ const y0 = box.top + box.height * 0.3;
126
+ const x1 = box.left + box.width * 0.7;
127
+ const y1 = box.top + box.height * 0.7;
128
+ const target = document.elementFromPoint(x0, y0) as Element;
129
+ const fire = (type: string, x: number, y: number, buttons: number): void => {
130
+ target.dispatchEvent(
131
+ new PointerEvent(type, {
132
+ bubbles: true,
133
+ cancelable: true,
134
+ composed: true,
135
+ pointerId: 1,
136
+ pointerType: 'mouse',
137
+ isPrimary: true,
138
+ button: 0,
139
+ buttons,
140
+ clientX: x,
141
+ clientY: y,
142
+ }),
143
+ );
144
+ };
145
+ fire('pointerdown', x0, y0, 1);
146
+ for (let i = 1; i <= 8; i++) {
147
+ fire('pointermove', x0 + ((x1 - x0) * i) / 8, y0 + ((y1 - y0) * i) / 8, 1);
148
+ await new Promise((r) => requestAnimationFrame(r));
149
+ }
150
+ fire('pointerup', x1, y1, 0);
151
+ await new Promise((r) => requestAnimationFrame(r));
152
+ }
153
+
154
+ test('an expanded trial keeps its marks over it and takes new ones', async () => {
155
+ render(
156
+ <div className="lk-lightbox-lab-host">
157
+ <Lab instruments={[Marked]} defaultInstrument="Marked" />
158
+ </div>,
159
+ );
160
+ await userEvent.click(await screen.findByRole('button', { name: 'Rectangle' }));
161
+ const pane = document.querySelector('.lk-lightbox-lab-pane') as HTMLElement;
162
+ await waitFor(() => expect(document.querySelector('.lk-annotate__input')).not.toBeNull());
163
+
164
+ await drawRect(pane.getBoundingClientRect());
165
+ await waitFor(() => expect(screen.getByTestId('count').textContent).toBe('1'));
166
+
167
+ await userEvent.click(screen.getByRole('button', { name: 'Expand trial' }));
168
+ const box = pane.closest('.lk-lightbox') as HTMLElement;
169
+ expect(box.matches(':popover-open')).toBe(true);
170
+ const big = pane.getBoundingClientRect();
171
+ expect(big.width).toBeGreaterThan(window.innerWidth * 0.5);
172
+
173
+ // The input box followed the pane into the top layer, and is what a press
174
+ // over the pane reaches.
175
+ const input = document.querySelector('.lk-annotate__input') as HTMLElement;
176
+ await waitFor(() => {
177
+ const r = input.getBoundingClientRect();
178
+ expect(r.left).toBeCloseTo(big.left, 0);
179
+ expect(r.width).toBeCloseTo(big.width, 0);
180
+ });
181
+ expect(document.elementFromPoint(big.left + big.width / 2, big.top + big.height / 2)).toBe(input);
182
+
183
+ // The first mark is drawn over the expanded pane: somewhere along the
184
+ // pane's middle row the rectangle's stroke crosses it, and the margins of
185
+ // that row, outside the mark, are still the pane's flat blue.
186
+ await waitFor(
187
+ async () => {
188
+ const row = await rowAt(big.top + big.height / 2, big.left + 2, big.right - 2);
189
+ expect(row[0]?.join()).toBe(BLUE);
190
+ expect(row.at(-1)?.join()).toBe(BLUE);
191
+ expect(row.filter((p) => p.join() !== BLUE).length).toBeGreaterThan(0);
192
+ },
193
+ { timeout: 5000 },
194
+ );
195
+
196
+ await drawRect(big);
197
+ await waitFor(() => expect(screen.getByTestId('count').textContent).toBe('2'));
198
+
199
+ // The under buffer was lifted below the trial and covers the window, and the
200
+ // tenant painted the expanded solid's rect there.
201
+ const under = document.querySelector('.lk-lab__layer--under') as HTMLElement;
202
+ expect(under.matches(':popover-open')).toBe(true);
203
+ expect(under.getBoundingClientRect().width).toBeCloseTo(window.innerWidth, 0);
204
+ const solid = (
205
+ document.querySelector('.lk-lightbox-lab-solid') as HTMLElement
206
+ ).getBoundingClientRect();
207
+ expect(solid.width).toBeGreaterThan(0);
208
+ await waitFor(() =>
209
+ expect(underPixelAt(solid.left + solid.width / 2, solid.top + solid.height / 2)).toEqual([
210
+ 0, 255, 0,
211
+ ]),
212
+ );
213
+
214
+ await userEvent.keyboard('{Escape}');
215
+ expect(box.matches(':popover-open')).toBe(false);
216
+ });
@@ -118,20 +118,27 @@
118
118
  // the pane can still hold a label or a control. Both sit under the viewport
119
119
  // chrome, whose z-overlay outranks them, and both are inert — every tile takes
120
120
  // input from its own box.
121
- .lk-lab__surface {
121
+ // The shared surface's two layers, over the whole body and taking no input.
122
+ // The over layer is also the box tile rects are measured against.
123
+ .lk-lab__layer {
122
124
  position: absolute;
123
125
  inset: 0;
124
126
  pointer-events: none;
125
127
  }
126
128
 
127
- .lk-lab__surface--under {
129
+ .lk-lab__layer--under {
128
130
  z-index: 0;
129
131
  }
130
132
 
131
- .lk-lab__surface--over {
133
+ .lk-lab__layer--over {
132
134
  z-index: 1;
133
135
  }
134
136
 
137
+ .lk-lab__surface {
138
+ position: absolute;
139
+ inset: 0;
140
+ }
141
+
135
142
  .lk-shell-footer {
136
143
  border-top: 1px solid var(--wzl-border);
137
144
  padding: var(--wzl-space-xs) var(--wzl-space-md);
@@ -0,0 +1,129 @@
1
+ import { type ReactNode, useCallback, useInsertionEffect, useMemo, useRef, useState } from 'react';
2
+ import { LightboxLayers } from '../lightbox/LightboxLayers';
3
+ import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
4
+ import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
5
+ import { type SurfaceFrame, useTiledSurface } from '../surface/useTiledSurface';
6
+
7
+ /** Props for `LabSurface`. */
8
+ export interface LabSurfaceProps {
9
+ /** Receives the lab body, which the surface's layers cover. */
10
+ bodyRef: (el: HTMLDivElement | null) => void;
11
+ children: ReactNode;
12
+ }
13
+
14
+ /**
15
+ * The lab body and the one shared drawing surface its trials paint into.
16
+ *
17
+ * The two buffers sit in layers stacked around the trials, both taking no
18
+ * input — each tile takes it from its own box. A tile's marks annotate the
19
+ * instrument's DOM from over it; an opaque renderer sits under it, so the
20
+ * pane can still hold a label. Tile rects are measured against the over
21
+ * layer, which is also where a tile's own chrome on the surface (an
22
+ * annotation input box) mounts. The layers are declared to any lightbox
23
+ * inside, which lifts them around an expanded trial; while lifted they cover
24
+ * the window, the surface re-measures against that, and the buffers resize.
25
+ *
26
+ * A host that already owns a surface keeps it: a lab embedded in a larger
27
+ * shared-surface app must not open a second GL tenancy, and mounts no buffer
28
+ * or layer of its own.
29
+ */
30
+ export function LabSurface({ bodyRef, children }: LabSurfaceProps) {
31
+ const outerSurface = useSurfaceOptional();
32
+ const outerOver = useSurfaceCanvas('over');
33
+ const outerUnder = useSurfaceCanvas('under');
34
+ const [ownOver, setOwnOver] = useState<HTMLCanvasElement | null>(null);
35
+ const [ownUnder, setOwnUnder] = useState<HTMLCanvasElement | null>(null);
36
+ const [underLayer, setUnderLayer] = useState<HTMLDivElement | null>(null);
37
+ const [overLayer, setOverLayer] = useState<HTMLDivElement | null>(null);
38
+ const ownOverRef = useRef<HTMLCanvasElement | null>(null);
39
+ const ownUnderRef = useRef<HTMLCanvasElement | null>(null);
40
+ const bufferRef = useRef({ w: 0, h: 0 });
41
+ const surfaceRef = useRef<ReturnType<typeof useTiledSurface> | null>(null);
42
+
43
+ // Sizing the buffer clears all of it, so every tile has to repaint — not
44
+ // only the one whose move triggered the measurement. Both buffers are sized
45
+ // together off one comparison: they are the same box, so a tenant of either
46
+ // is looking at the same rects, and one invalidateAll covers both.
47
+ const onFrame = useCallback((frame: SurfaceFrame) => {
48
+ const canvases = [ownUnderRef.current, ownOverRef.current].filter((c) => c !== null);
49
+ if (canvases.length === 0) return;
50
+ const w = Math.round(frame.size.width * frame.dpr);
51
+ const h = Math.round(frame.size.height * frame.dpr);
52
+ if (bufferRef.current.w !== w || bufferRef.current.h !== h) {
53
+ bufferRef.current = { w, h };
54
+ for (const c of canvases) {
55
+ c.width = w;
56
+ c.height = h;
57
+ c.style.width = `${frame.size.width}px`;
58
+ c.style.height = `${frame.size.height}px`;
59
+ }
60
+ surfaceRef.current?.invalidateAll();
61
+ return;
62
+ }
63
+ // A same-size re-tile does not go through here: assigning `width` its own
64
+ // value resizes nothing, so it clears nothing. The tenants clear, through
65
+ // `registerClear`.
66
+ }, []);
67
+
68
+ const ownSurface = useTiledSurface({ onFrame });
69
+ // Not `useLatest`: `onFrame` reads it, and is needed before the surface exists.
70
+ useInsertionEffect(() => {
71
+ surfaceRef.current = ownSurface;
72
+ }, [ownSurface]);
73
+
74
+ const attachOwnSurface = ownSurface.containerRef;
75
+ const overLayerRef = useCallback(
76
+ (el: HTMLDivElement | null) => {
77
+ setOverLayer(el);
78
+ attachOwnSurface(el);
79
+ },
80
+ [attachOwnSurface],
81
+ );
82
+
83
+ const surface = outerSurface ?? ownSurface;
84
+ const canvases = useMemo(
85
+ () =>
86
+ outerSurface ? { over: outerOver, under: outerUnder } : { over: ownOver, under: ownUnder },
87
+ [outerSurface, outerOver, outerUnder, ownOver, ownUnder],
88
+ );
89
+ const below = useMemo(() => [underLayer], [underLayer]);
90
+ const above = useMemo(() => [overLayer], [overLayer]);
91
+
92
+ const body = (
93
+ <div className="lk-lab__body" ref={bodyRef}>
94
+ {outerSurface ? null : (
95
+ <div className="lk-lab__layer lk-lab__layer--under" ref={setUnderLayer}>
96
+ <canvas
97
+ className="lk-lab__surface"
98
+ ref={(el) => {
99
+ ownUnderRef.current = el;
100
+ setOwnUnder(el);
101
+ }}
102
+ />
103
+ </div>
104
+ )}
105
+ {children}
106
+ {outerSurface ? null : (
107
+ <div className="lk-lab__layer lk-lab__layer--over" ref={overLayerRef}>
108
+ <canvas
109
+ className="lk-lab__surface"
110
+ ref={(el) => {
111
+ ownOverRef.current = el;
112
+ setOwnOver(el);
113
+ }}
114
+ />
115
+ </div>
116
+ )}
117
+ </div>
118
+ );
119
+
120
+ return (
121
+ <SurfaceContext.Provider value={surface}>
122
+ <SurfaceCanvasContext.Provider value={canvases}>
123
+ <LightboxLayers below={below} above={above}>
124
+ {body}
125
+ </LightboxLayers>
126
+ </SurfaceCanvasContext.Provider>
127
+ </SurfaceContext.Provider>
128
+ );
129
+ }
@@ -17,6 +17,8 @@ const Tile = ({ children }: { children: React.ReactNode }) => (
17
17
  borderRadius: 'var(--wzl-radius-md)',
18
18
  padding: 'var(--wzl-space-md)',
19
19
  minHeight: 120,
20
+ height: '100%',
21
+ boxSizing: 'border-box',
20
22
  display: 'grid',
21
23
  placeItems: 'center',
22
24
  }}
@@ -75,3 +77,19 @@ export const Resizable: Story = {
75
77
  </div>
76
78
  ),
77
79
  };
80
+
81
+ /** Every tile has an expand button in its corner that shows it large over the
82
+ * page. The tile is lifted into the browser's top layer rather than
83
+ * remounted; Escape, the close button, or a click in the margin puts it back.
84
+ * Tile 1 also opens on a double-click, through `expandOnDoubleClick`. */
85
+ export const Lightbox: Story = {
86
+ render: () => (
87
+ <div style={{ height: 500 }}>
88
+ <Workspace ids={['a', 'b', 'c']} resizable expandOnDoubleClick={(id) => id === 'a'}>
89
+ <Tile>1</Tile>
90
+ <Tile>2</Tile>
91
+ <Tile>3</Tile>
92
+ </Workspace>
93
+ </div>
94
+ ),
95
+ };
@@ -10,11 +10,15 @@ function fakeHandle(): SurfaceHandle {
10
10
  invalidate: vi.fn(),
11
11
  invalidateAll: vi.fn(),
12
12
  invalidateRects: vi.fn(),
13
+ invalidateBox: vi.fn(),
13
14
  registerTile: vi.fn(),
14
15
  registerClear: vi.fn(() => () => {}),
15
16
  registerPainter: vi.fn(() => () => {}),
16
17
  containerRef: vi.fn(),
17
18
  getContainer: vi.fn(() => null),
19
+ scope: vi.fn(),
20
+ inScope: vi.fn(() => true),
21
+ subscribeScope: vi.fn(() => () => {}),
18
22
  };
19
23
  }
20
24
 
@@ -1,4 +1,4 @@
1
- import { act, screen } from '@testing-library/react';
1
+ import { act, fireEvent, screen } from '@testing-library/react';
2
2
  import { renderSettled } from '@weasel-js/react/testing/renderSettled';
3
3
  import { describe, expect, test, vi } from 'vitest';
4
4
  import { useTrialDrag } from '../trial/TrialDragContext';
@@ -50,6 +50,42 @@ describe('Workspace', () => {
50
50
  expect(container.querySelector('[data-node="a"]')).toBeNull();
51
51
  });
52
52
 
53
+ test('opens a tile on double-click only where expandOnDoubleClick says so', async () => {
54
+ const { container } = await renderSettled(
55
+ <Workspace ids={['a', 'b']} viewport={VIEWPORT} expandOnDoubleClick={(id) => id === 'a'}>
56
+ <div>a</div>
57
+ <div>b</div>
58
+ </Workspace>,
59
+ );
60
+ const box = (id: string) => container.querySelector(`[data-node="${id}"] .lk-lightbox`);
61
+ expect(screen.getAllByRole('button', { name: 'Expand' })).toHaveLength(2);
62
+ fireEvent.doubleClick(screen.getByText('b'));
63
+ expect(box('b')).not.toHaveClass('lk-lightbox--expanded');
64
+ fireEvent.doubleClick(screen.getByText('a'));
65
+ expect(box('a')).toHaveClass('lk-lightbox--expanded');
66
+ });
67
+
68
+ test('lets a predicate take the lightbox off one tile', async () => {
69
+ await renderSettled(
70
+ <Workspace ids={['a', 'b']} viewport={VIEWPORT} lightbox={(id) => id !== 'b'}>
71
+ <div>a</div>
72
+ <div>b</div>
73
+ </Workspace>,
74
+ );
75
+ expect(screen.getAllByRole('button', { name: 'Expand' })).toHaveLength(1);
76
+ });
77
+
78
+ test('lightbox={false} leaves no tile expandable', async () => {
79
+ await renderSettled(
80
+ <Workspace viewport={VIEWPORT} lightbox={false} expandOnDoubleClick>
81
+ <div>a</div>
82
+ </Workspace>,
83
+ );
84
+ fireEvent.doubleClick(screen.getByText('a'));
85
+ expect(document.querySelector('.lk-lightbox--expanded')).toBeNull();
86
+ expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull();
87
+ });
88
+
53
89
  test('uses lk-workspace class', async () => {
54
90
  const { container } = await renderSettled(
55
91
  <Workspace viewport={VIEWPORT}>
@@ -17,6 +17,7 @@ import {
17
17
  StrategyRegistryProvider,
18
18
  } from 'windease/react';
19
19
 
20
+ import { Lightbox } from '../lightbox/Lightbox';
20
21
  import { useSurfaceOptional } from '../surface/useSurfaceTile';
21
22
  import { TrialDragContext } from '../trial/TrialDragContext';
22
23
  import { usePanelHosts } from './panelHost';
@@ -66,6 +67,19 @@ export interface WorkspaceProps {
66
67
  * floating zone above it. The body is portaled in by the trial that owns
67
68
  * it, so all this renders is the frame and the host. */
68
69
  panels?: readonly PanelDescriptor[];
70
+ /**
71
+ * Whether a tile can open in a `<Lightbox>` — shown large over the page from
72
+ * the expand button in its corner — without being remounted. On by default;
73
+ * `false` turns it off for every tile, and a function decides per tile id.
74
+ */
75
+ lightbox?: boolean | ((id: string) => boolean);
76
+ /**
77
+ * Whether a double-click on a tile's content also opens its lightbox. Off by
78
+ * default, since most content has its own use for a double-click; give it
79
+ * to a tile whose content has none, such as an image, with a function of
80
+ * the tile id.
81
+ */
82
+ expandOnDoubleClick?: boolean | ((id: string) => boolean);
69
83
  /**
70
84
  * Fixed tiling extent. Omit in an app — the grid measures its own box. Supply
71
85
  * it where nothing measures, notably jsdom: at a zero measurement the grid
@@ -149,6 +163,8 @@ export function Workspace({
149
163
  gap = 12,
150
164
  padding = 0,
151
165
  viewport,
166
+ lightbox = true,
167
+ expandOnDoubleClick = false,
152
168
  }: WorkspaceProps) {
153
169
  const items = Children.toArray(children);
154
170
  const tilePanels = useMemo(() => (panels ?? []).filter((p) => p.as === 'tile'), [panels]);
@@ -226,6 +242,9 @@ export function Workspace({
226
242
  );
227
243
 
228
244
  const chrome = useMemo<ChromeMap>(() => {
245
+ const lightboxFor = typeof lightbox === 'function' ? lightbox : () => lightbox;
246
+ const doubleClickFor =
247
+ typeof expandOnDoubleClick === 'function' ? expandOnDoubleClick : () => expandOnDoubleClick;
229
248
  const byId = new Map<string, ReactNode>(nodeIds.map((id, i) => [id, items[i]]));
230
249
  const panelByNode = new Map<string, PanelDescriptor>(
231
250
  tilePanels.map((p) => [`lk-panel-${p.key}`, p]),
@@ -237,17 +256,26 @@ export function Workspace({
237
256
  return <PanelFrame panel={panel} />;
238
257
  },
239
258
  [KIND]: ({ node }) => {
240
- const content = byId.get(node.id) ?? null;
241
- if (!reorderable) return content;
259
+ const id = String(node.id);
260
+ const tile = (
261
+ // `disabled` rather than leaving the wrapper out, so turning the
262
+ // lightbox off cannot remount the tile.
263
+ <Lightbox
264
+ className="lk-trial-tile"
265
+ disabled={!lightboxFor(id)}
266
+ expandOnDoubleClick={doubleClickFor(id)}
267
+ >
268
+ {byId.get(node.id) ?? null}
269
+ </Lightbox>
270
+ );
271
+ if (!reorderable) return tile;
242
272
  return (
243
273
  // No grip strip: the trial's own title bar is the drag surface.
244
- <TrialDragContext.Provider value={{ nodeId: node.id }}>
245
- <div className="lk-trial-tile">{content}</div>
246
- </TrialDragContext.Provider>
274
+ <TrialDragContext.Provider value={{ nodeId: node.id }}>{tile}</TrialDragContext.Provider>
247
275
  );
248
276
  },
249
277
  };
250
- }, [nodeIds, items, reorderable, tilePanels]);
278
+ }, [nodeIds, items, reorderable, tilePanels, lightbox, expandOnDoubleClick]);
251
279
 
252
280
  const floatingLayer =
253
281
  floatPanels.length === 0 ? null : (
package/src/lab/index.ts CHANGED
@@ -12,5 +12,7 @@ export { LabShell } from './LabShell';
12
12
  export type { LabPage, LabSwitcherProps } from './LabSwitcher';
13
13
  export { currentPage, LabSwitcher } from './LabSwitcher';
14
14
  export { LabZoom } from './LabZoom';
15
+ export type { PresentationSeed } from './openLab';
16
+ export { type Presentation, usePresentation } from './presentation';
15
17
  export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
16
18
  export { Workspace } from './Workspace';
@@ -0,0 +1,30 @@
1
+ import { type ColorList, colorAt, colorCount, resolveTheme, type Theme } from '@weasel-js/theme';
2
+
3
+ // Fixed spread of nebula blob positions / sizes / fall-off stops. Colors
4
+ // supplied via the `nebula` prop are slotted into these slots in order;
5
+ // callers passing more than 5 wrap around (intentional — keeps the look
6
+ // readable and bounded).
7
+ const NEBULA_SLOTS = [
8
+ { cx: '18%', cy: '30%', sx: '60%', sy: '80%', stop: '70%' },
9
+ { cx: '78%', cy: '70%', sx: '55%', sy: '90%', stop: '65%' },
10
+ { cx: '50%', cy: '50%', sx: '50%', sy: '70%', stop: '75%' },
11
+ { cx: '12%', cy: '82%', sx: '50%', sy: '70%', stop: '70%' },
12
+ { cx: '85%', cy: '18%', sx: '55%', sy: '80%', stop: '70%' },
13
+ ] as const;
14
+
15
+ export function buildNebula(
16
+ list: ColorList,
17
+ theme: Theme,
18
+ mode: 'light' | 'dark',
19
+ density: string,
20
+ ): string {
21
+ const ctx = { theme, resolved: resolveTheme(theme, { mode, density }) };
22
+ const n = colorCount(list, ctx) ?? NEBULA_SLOTS.length;
23
+ const colors = Array.from({ length: n }, (_, i) => colorAt(list, i, ctx));
24
+ const blobs = colors.map((c, i) => {
25
+ const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
26
+ return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
27
+ });
28
+ blobs.push('radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)');
29
+ return blobs.join(', ');
30
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Instrument, InstrumentList } from '../instrument/types';
3
+ import { createMemoryAdapter } from '../state/adapters';
4
+ import { openStoredLab, openUnstoredLab, presentStorageKey } from './openLab';
5
+
6
+ const counter: Instrument<{ count: number }, { n: number }> = {
7
+ name: 'Counter',
8
+ defaultConfig: () => ({ n: 1 }),
9
+ initialState: (config) => ({ count: config.n * 10 }),
10
+ render: () => null,
11
+ };
12
+ const other: Instrument<{ k: string }, { k: string }> = {
13
+ name: 'Other',
14
+ defaultConfig: () => ({ k: 'a' }),
15
+ initialState: (config) => ({ k: config.k }),
16
+ render: () => null,
17
+ };
18
+ const instruments = [counter, other] as unknown as InstrumentList;
19
+ const base = { instruments, defaultInstrument: 'Counter' };
20
+
21
+ describe('presentStorageKey', () => {
22
+ it('keeps a presented lab apart from the full one', () => {
23
+ expect(presentStorageKey('rosee')).toBe('rosee:present');
24
+ });
25
+ });
26
+
27
+ describe('opening a presented lab', () => {
28
+ it('opens one trial on the seed', () => {
29
+ const { store } = openUnstoredLab(base, {
30
+ instrument: 'Other',
31
+ config: { k: 'b' },
32
+ });
33
+ const trials = store.getState().trials;
34
+ expect(trials).toHaveLength(1);
35
+ expect(trials[0]).toMatchObject({
36
+ instrumentName: 'Other',
37
+ config: { k: 'b' },
38
+ state: { k: 'b' },
39
+ });
40
+ });
41
+
42
+ it('takes a seeded state and view over the ones the config would give', () => {
43
+ const { store } = openUnstoredLab(base, { config: { n: 2 }, state: { count: 7 }, view: 'v' });
44
+ expect(store.getState().trials[0]).toMatchObject({
45
+ instrumentName: 'Counter',
46
+ config: { n: 2 },
47
+ state: { count: 7 },
48
+ view: 'v',
49
+ });
50
+ });
51
+
52
+ it("keeps a returning visitor's trial while the seed is unchanged", async () => {
53
+ const storage = createMemoryAdapter();
54
+ const seed = { config: { n: 2 } };
55
+ const first = await openStoredLab(base, 'lab', storage, seed);
56
+ const id = first.store.getState().trials[0]?.id;
57
+ first.store.getState().updateTrialState(id as string, { count: 99 });
58
+ await first.close();
59
+
60
+ const second = await openStoredLab(base, 'lab', storage, { config: { n: 2 } });
61
+ expect(second.store.getState().trials).toHaveLength(1);
62
+ expect(second.store.getState().trials[0]).toMatchObject({ id, state: { count: 99 } });
63
+ await second.close();
64
+ });
65
+
66
+ it('reopens the trial on a seed that changed', async () => {
67
+ const storage = createMemoryAdapter();
68
+ const first = await openStoredLab(base, 'lab', storage, { config: { n: 2 } });
69
+ await first.close();
70
+
71
+ const second = await openStoredLab(base, 'lab', storage, { config: { n: 3 } });
72
+ const trials = second.store.getState().trials;
73
+ expect(trials).toHaveLength(1);
74
+ expect(trials[0]).toMatchObject({ config: { n: 3 }, state: { count: 30 } });
75
+ await second.close();
76
+
77
+ const third = await openStoredLab(base, 'lab', storage, { config: { n: 3 } });
78
+ expect(third.store.getState().trials[0]?.id).toBe(trials[0]?.id);
79
+ await third.close();
80
+ });
81
+ });