@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.
- package/README.md +24 -0
- package/dist/_dts/{CanvasStackContext-Cjy6I-Iv.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
- package/dist/_dts/{Stage-D6t7o0pF.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
- package/dist/_dts/{index-DCMA4Fy1.d.ts → index-8geQNQet.d.ts} +84 -12
- package/dist/_dts/{index-BTmaRy6a.d.ts → index-DRi3V5sN.d.ts} +6 -4
- package/dist/_dts/{types-BUBSIZuV.d.ts → types-DUfQ0isU.d.ts} +5 -1
- package/dist/_dts/{useTrialState-Bh8bzFgE.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
- package/dist/canvas/index.d.ts +5 -5
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +3 -3
- package/dist/chrome/index.js +7 -7
- package/dist/{chunk-HBGH344L.js → chunk-4RCVWX4M.js} +55 -18
- package/dist/chunk-4RCVWX4M.js.map +1 -0
- package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
- package/dist/chunk-6IIKLXBZ.js.map +1 -0
- package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
- package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
- package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
- package/dist/chunk-7ALJOVGP.js.map +1 -0
- package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
- package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
- package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
- package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
- package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
- package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
- package/dist/{chunk-LPGBC63T.js → chunk-KHB3KRRQ.js} +7 -7
- package/dist/chunk-KHB3KRRQ.js.map +1 -0
- package/dist/{chunk-IBVRMHW5.js → chunk-NHIO2O3O.js} +31 -30
- package/dist/chunk-NHIO2O3O.js.map +1 -0
- package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
- package/dist/chunk-QMAY74TP.js.map +1 -0
- package/dist/{chunk-4X44DMRS.js → chunk-RN7T7OBO.js} +46 -24
- package/dist/chunk-RN7T7OBO.js.map +1 -0
- package/dist/{chunk-4TJ34FXA.js → chunk-RXO5UEGT.js} +5 -12
- package/dist/chunk-RXO5UEGT.js.map +1 -0
- package/dist/{chunk-E6NARGU7.js → chunk-SQX35X5V.js} +3 -3
- package/dist/{chunk-E6NARGU7.js.map → chunk-SQX35X5V.js.map} +1 -1
- package/dist/{chunk-YCFKW5RX.js → chunk-T5OEUNCB.js} +16 -2
- package/dist/chunk-T5OEUNCB.js.map +1 -0
- package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
- package/dist/chunk-UX66USTN.js.map +1 -0
- package/dist/{chunk-IRMPZIYU.js → chunk-V4SMGEYY.js} +6732 -6507
- package/dist/chunk-V4SMGEYY.js.map +1 -0
- package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
- package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
- package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
- package/dist/chunk-XWR2P6MR.js.map +1 -0
- package/dist/{chunk-XSJJTXNA.js → chunk-YZA73U4A.js} +19 -19
- package/dist/chunk-YZA73U4A.js.map +1 -0
- package/dist/config/index.d.ts +12 -5
- package/dist/config/index.js +2 -2
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +2 -2
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +158 -16
- package/dist/index.js +1036 -415
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +176 -28
- package/dist/loupe/index.js +547 -141
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.d.ts +1 -1
- package/dist/overview/index.js +5 -5
- package/dist/passthrough/weasel-ui.js +3 -3
- package/dist/primitives/index.d.ts +4 -19
- package/dist/primitives/index.js +7 -7
- package/dist/state/index.d.ts +6 -8
- package/dist/state/index.js +5 -5
- package/dist/styles.css +181 -44
- package/dist/surface/index.d.ts +20 -1
- package/dist/surface/index.js +2 -2
- package/dist/undo/index.d.ts +19 -8
- package/dist/undo/index.js +1 -1
- package/package.json +11 -11
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
- package/src/annotations/AnnotationOverlay.tsx +10 -1
- package/src/annotations/Annotations.less +2 -0
- package/src/annotations/Annotations.overlay.test.tsx +29 -0
- package/src/canvas/CanvasStack.test.tsx +35 -0
- package/src/canvas/CanvasStack.tsx +12 -1
- package/src/canvas/useLayerScheduler.ts +14 -0
- package/src/chrome/builtins.test.ts +14 -0
- package/src/chrome/builtins.tsx +17 -2
- package/src/chrome/regions/StatusRegion.tsx +20 -14
- package/src/chrome/regions/regions.test.tsx +28 -0
- package/src/chrome/types.ts +8 -0
- package/src/clock/clock.browser.test.less +5 -0
- package/src/clock/clock.browser.test.tsx +77 -0
- package/src/clock/clock.integration.test.tsx +95 -0
- package/src/clock/clockRegistry.test.ts +33 -0
- package/src/clock/clockRegistry.ts +46 -0
- package/src/clock/hooks.ts +44 -0
- package/src/clock/index.ts +2 -0
- package/src/clock/trialClock.test.ts +265 -0
- package/src/clock/trialClock.ts +245 -0
- package/src/clock/useClockLoop.ts +45 -0
- package/src/config/builder.ts +11 -1
- package/src/config/resolve.test.ts +12 -0
- package/src/config/resolve.ts +5 -0
- package/src/config/types.ts +5 -1
- package/src/controls/ControlMatrix.test.tsx +10 -0
- package/src/controls/ControlMatrix.tsx +110 -58
- package/src/controls/ControlPanel.test.tsx +30 -0
- package/src/index.ts +3 -2
- package/src/instrument/types.ts +21 -1
- package/src/lab/Lab.chrome.test.tsx +8 -1
- package/src/lab/Lab.presentation.test.tsx +113 -0
- package/src/lab/Lab.surface.test.tsx +20 -9
- package/src/lab/Lab.tsx +132 -257
- package/src/lab/LabLightbox.browser.test.less +17 -0
- package/src/lab/LabLightbox.browser.test.tsx +216 -0
- package/src/lab/LabShell.less +10 -3
- package/src/lab/LabSurface.tsx +129 -0
- package/src/lab/Workspace.stories.tsx +18 -0
- package/src/lab/Workspace.surface.test.tsx +4 -0
- package/src/lab/Workspace.test.tsx +37 -1
- package/src/lab/Workspace.tsx +34 -6
- package/src/lab/index.ts +2 -0
- package/src/lab/nebula.ts +30 -0
- package/src/lab/openLab.test.ts +81 -0
- package/src/lab/openLab.ts +119 -0
- package/src/lab/presentation.browser.test.less +5 -0
- package/src/lab/presentation.browser.test.tsx +119 -0
- package/src/lab/presentation.less +64 -0
- package/src/lab/presentation.tsx +47 -0
- package/src/lab/useFocusPick.ts +43 -0
- package/src/lightbox/Lightbox.browser.test.less +14 -0
- package/src/lightbox/Lightbox.browser.test.tsx +113 -0
- package/src/lightbox/Lightbox.less +128 -0
- package/src/lightbox/Lightbox.test.tsx +195 -0
- package/src/lightbox/Lightbox.tsx +205 -0
- package/src/lightbox/LightboxContext.ts +38 -0
- package/src/lightbox/LightboxLayers.tsx +40 -0
- package/src/lightbox/index.ts +8 -0
- package/src/lightbox/modal.ts +94 -0
- package/src/loupe/AGENTS.md +40 -2
- package/src/loupe/CanvasLoupe.tsx +5 -4
- package/src/loupe/DomLoupe.tsx +7 -5
- package/src/loupe/Loupe.less +15 -0
- package/src/loupe/LoupeBubble.tsx +35 -13
- package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
- package/src/loupe/SourceLoupe.tsx +60 -0
- package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
- package/src/loupe/TrialLoupe.tsx +126 -28
- package/src/loupe/canvasLens.ts +20 -17
- package/src/loupe/index.ts +26 -2
- package/src/loupe/sourceLens.test.ts +65 -0
- package/src/loupe/sourceLens.ts +97 -0
- package/src/loupe/types.test.ts +6 -0
- package/src/loupe/types.ts +109 -7
- package/src/loupe/useLoupe.test.tsx +188 -3
- package/src/loupe/useLoupe.ts +106 -11
- package/src/primitives/index.ts +2 -2
- package/src/specimen/Specimen.tsx +0 -7
- package/src/state/helpers.test.ts +4 -11
- package/src/state/helpers.ts +2 -8
- package/src/state/index.ts +0 -2
- package/src/state/openLabStore.test.ts +10 -8
- package/src/state/openLabStore.ts +1 -1
- package/src/state/store.test.ts +2 -2
- package/src/state/store.ts +15 -16
- package/src/state/types.ts +9 -9
- package/src/styles.less +2 -1
- package/src/surface/AGENTS.md +13 -0
- package/src/surface/index.ts +1 -0
- package/src/surface/useSurfaceTile.test.tsx +4 -0
- package/src/surface/useSurfaceTile.ts +17 -1
- package/src/surface/useTiledSurface.test.tsx +65 -0
- package/src/surface/useTiledSurface.ts +68 -1
- package/src/theme/interstellar.theme.json +0 -14
- package/src/trial/Trial.stories.tsx +0 -1
- package/src/trial/Trial.test.tsx +40 -3
- package/src/trial/Trial.tsx +87 -43
- package/src/trial/TrialChrome.tsx +20 -2
- package/src/trial/trialOps.test.ts +1 -2
- package/src/trial/trialOps.ts +2 -3
- package/src/undo/stateOp.ts +21 -0
- package/src/undo/undoStack.ts +18 -6
- package/dist/chunk-4TJ34FXA.js.map +0 -1
- package/dist/chunk-4X44DMRS.js.map +0 -1
- package/dist/chunk-EESJUSYR.js.map +0 -1
- package/dist/chunk-HBGH344L.js.map +0 -1
- package/dist/chunk-IBVRMHW5.js.map +0 -1
- package/dist/chunk-IRMPZIYU.js.map +0 -1
- package/dist/chunk-LPGBC63T.js.map +0 -1
- package/dist/chunk-PMLZP7RT.js.map +0 -1
- package/dist/chunk-RL2LOLNI.js.map +0 -1
- package/dist/chunk-T6DLGEJB.js.map +0 -1
- package/dist/chunk-WIG6XHJ7.js.map +0 -1
- package/dist/chunk-XSJJTXNA.js.map +0 -1
- package/dist/chunk-YCFKW5RX.js.map +0 -1
- package/src/primitives/StatusBar.less +0 -34
- package/src/primitives/StatusBar.stories.tsx +0 -24
- package/src/primitives/StatusBar.test.tsx +0 -25
- 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
|
+
});
|
package/src/lab/LabShell.less
CHANGED
|
@@ -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
|
-
.
|
|
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-
|
|
129
|
+
.lk-lab__layer--under {
|
|
128
130
|
z-index: 0;
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
.lk-
|
|
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}>
|
package/src/lab/Workspace.tsx
CHANGED
|
@@ -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
|
|
241
|
-
|
|
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
|
+
});
|