@weasel-js/labkit 1.0.3 → 1.1.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.
- package/README.md +6 -6
- package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
- package/dist/chunk-NRKWVTVT.js.map +1 -0
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +197 -148
- package/dist/index.js +275 -161
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +10 -3
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +14 -14
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +179 -14
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +2 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +56 -44
- package/src/lab/LabContext.ts +12 -11
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/Workspace.less +40 -0
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
- package/src/lab/Workspace.test.tsx +114 -0
- package/src/lab/Workspace.tsx +196 -0
- package/src/lab/index.ts +2 -4
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +23 -31
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -40
- package/src/state/store.ts +166 -90
- package/src/state/types.ts +30 -10
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/test-setup.ts +19 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -12
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/chunk-3WPOGKUP.js.map +0 -1
- package/dist/chunk-73PXCRCR.js.map +0 -1
- package/dist/chunk-N5KTQKQA.js.map +0 -1
- package/dist/chunk-T7OKNJTY.js +0 -351
- package/dist/chunk-T7OKNJTY.js.map +0 -1
- package/src/lab/WorkspaceGrid.less +0 -8
- package/src/lab/WorkspaceGrid.test.tsx +0 -40
- package/src/lab/WorkspaceGrid.tsx +0 -22
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -13
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/workspaceOps.ts +0 -86
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type {
|
|
|
23
23
|
DragFeedback,
|
|
24
24
|
HitResult,
|
|
25
25
|
Instrument,
|
|
26
|
+
InstrumentList,
|
|
26
27
|
LayerCapability,
|
|
27
28
|
LayerDescriptor,
|
|
28
29
|
PaletteItem,
|
|
@@ -48,25 +49,28 @@ export {
|
|
|
48
49
|
export {
|
|
49
50
|
LabStoreContext,
|
|
50
51
|
LabStoreProvider,
|
|
52
|
+
TrialIdContext,
|
|
53
|
+
TrialIdProvider,
|
|
51
54
|
useLabStore,
|
|
52
|
-
|
|
53
|
-
WorkspaceIdContext,
|
|
54
|
-
WorkspaceIdProvider,
|
|
55
|
+
useTrialId,
|
|
55
56
|
} from './state/context';
|
|
57
|
+
export { CURRENT_DOCUMENT_VERSION, labDocumentKey, quarantineKey } from './state/document';
|
|
56
58
|
export type {
|
|
57
59
|
CreateLabStoreOptions,
|
|
58
|
-
|
|
60
|
+
LabDocument,
|
|
59
61
|
LabMode,
|
|
60
62
|
LabStoreState,
|
|
61
63
|
SavedSnapshot,
|
|
64
|
+
SerializedTrial,
|
|
62
65
|
StorageAdapter,
|
|
66
|
+
TrialRecord,
|
|
67
|
+
TrialStateHandle,
|
|
63
68
|
UndoStack,
|
|
64
|
-
WorkspaceRecord,
|
|
65
69
|
} from './state/types';
|
|
66
|
-
export {
|
|
70
|
+
export { useTrialState } from './state/useTrialState';
|
|
67
71
|
export { interstellarTheme } from './theme/interstellar';
|
|
72
|
+
export * from './trial';
|
|
68
73
|
export * from './ui/layers';
|
|
69
74
|
export * from './ui/properties';
|
|
70
75
|
export type { EventBus, EventListener } from './undo';
|
|
71
76
|
export { clearUndo, createEventBus, emptyStack, pushSnapshot, redo, undo } from './undo';
|
|
72
|
-
export * from './workspace';
|
|
@@ -85,7 +85,7 @@ describe('SineWave instrument smoke test', () => {
|
|
|
85
85
|
config: SineWaveInstrument.defaultConfig(),
|
|
86
86
|
setState: vi.fn(),
|
|
87
87
|
setConfig: vi.fn(),
|
|
88
|
-
|
|
88
|
+
trial: { id: 'w', zoom: 1, setZoom: vi.fn() },
|
|
89
89
|
emit: vi.fn(),
|
|
90
90
|
};
|
|
91
91
|
const { container } = render(SineWaveInstrument.render(ctx) as ReactElement);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Instrument } from './types';
|
|
2
2
|
|
|
3
|
-
/** Which optional capabilities an instrument declared — what the
|
|
3
|
+
/** Which optional capabilities an instrument declared — what the trial
|
|
4
4
|
* consults to decide which chrome to show. */
|
|
5
5
|
export interface CapabilityFlags {
|
|
6
6
|
hasCanvas: boolean;
|
package/src/instrument/types.ts
CHANGED
|
@@ -2,13 +2,13 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import type { ConfigField } from '../controls/types';
|
|
3
3
|
|
|
4
4
|
/** What an instrument's `render` is handed: its state and config, the setters
|
|
5
|
-
* for both, the
|
|
5
|
+
* for both, the trial it is mounted in, and a way to emit named events. */
|
|
6
6
|
export interface RenderContext<TS = unknown, TC = unknown> {
|
|
7
7
|
state: TS;
|
|
8
8
|
config: TC;
|
|
9
9
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
10
10
|
setConfig: (key: keyof TC, value: unknown) => void;
|
|
11
|
-
|
|
11
|
+
trial: {
|
|
12
12
|
id: string;
|
|
13
13
|
zoom: number;
|
|
14
14
|
setZoom: (z: number) => void;
|
|
@@ -16,7 +16,11 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
16
16
|
emit: (event: string) => void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** One 2D canvas layer of an instrument, drawn in declaration order.
|
|
19
|
+
/** One 2D canvas layer of an instrument, drawn in declaration order.
|
|
20
|
+
*
|
|
21
|
+
* `draw` is called with the camera already applied, so it works in world
|
|
22
|
+
* coordinates. `zoom` is passed for the things that must not scale with it —
|
|
23
|
+
* set `ctx.lineWidth = 1 / zoom` to keep a hairline hairline. */
|
|
20
24
|
export interface CanvasLayer<TS = unknown, TC = unknown> {
|
|
21
25
|
id: string;
|
|
22
26
|
draw: (ctx: CanvasRenderingContext2D, args: { state: TS; config: TC; zoom: number }) => void;
|
|
@@ -29,7 +33,7 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
|
|
|
29
33
|
initialView?: { zoom: number; pan: { x: number; y: number } };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
/** Declares which of an instrument's layers the
|
|
36
|
+
/** Declares which of an instrument's layers the trial should offer
|
|
33
37
|
* show/hide controls for. */
|
|
34
38
|
export interface LayerCapability {
|
|
35
39
|
ids: string[];
|
|
@@ -59,7 +63,7 @@ export type SystemEvent = string;
|
|
|
59
63
|
export type Point = { x: number; y: number };
|
|
60
64
|
/** What a hit-test found, and where. */
|
|
61
65
|
export type HitResult = { hit: boolean; layerId?: string; pointId?: string };
|
|
62
|
-
/** A
|
|
66
|
+
/** A trial's camera. */
|
|
63
67
|
export type ViewTransform = { zoom: number; pan: Point };
|
|
64
68
|
/** A layer as the layer list shows it. `alwaysOn` layers cannot be hidden. */
|
|
65
69
|
export type LayerDescriptor = { id: string; label: string; alwaysOn?: boolean };
|
|
@@ -76,13 +80,15 @@ export type DragFeedback = { ok: boolean; reason?: string };
|
|
|
76
80
|
* and `state`, what the experiment is currently doing — and renders from both.
|
|
77
81
|
* The optional capability fields declare what else it wants from the runtime:
|
|
78
82
|
* a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
|
|
79
|
-
* is what makes the
|
|
83
|
+
* is what makes the trial provide the corresponding chrome.
|
|
80
84
|
*/
|
|
81
85
|
export interface Instrument<TS = unknown, TC = unknown> {
|
|
82
86
|
name: string;
|
|
83
87
|
defaultConfig: () => TC;
|
|
84
88
|
initialState: (config: TC) => TS;
|
|
85
89
|
configSchema?: () => ConfigField[];
|
|
90
|
+
/** The instrument's DOM. With `canvas`, this renders as an overlay above the
|
|
91
|
+
* layers rather than instead of them; return `null` for canvas only. */
|
|
86
92
|
render: (ctx: RenderContext<TS, TC>) => ReactNode;
|
|
87
93
|
onConfigChange?: (config: TC, prev: TC, state: TS) => TS;
|
|
88
94
|
serialize?: (state: TS) => unknown;
|
|
@@ -92,3 +98,9 @@ export interface Instrument<TS = unknown, TC = unknown> {
|
|
|
92
98
|
dragDrop?: DragDropCapability<TS, TC>;
|
|
93
99
|
undo?: UndoCapability;
|
|
94
100
|
}
|
|
101
|
+
|
|
102
|
+
/** Instruments as a lab receives them. `any` rather than `unknown` because
|
|
103
|
+
* parameter contravariance keeps a `defineInstrument<TS, TC>` result out of
|
|
104
|
+
* an `Instrument<unknown, unknown>[]`; it is contained to this alias. */
|
|
105
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
106
|
+
export type InstrumentList = readonly Instrument<any, any>[];
|
package/src/lab/Lab.less
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
// `height: 100%` resolves against the containing block, so the lab needs an
|
|
2
|
+
// unbroken chain of sized ancestors — and a host that supplies the height but
|
|
3
|
+
// not the margin reset gets a page 16px taller than the viewport, which reads
|
|
4
|
+
// as a stuck canvas rather than as overflow. Scoped with `:has` so a page that
|
|
5
|
+
// mounts no lab is untouched, and stopping at the lab's own parent so an
|
|
6
|
+
// embedded lab cannot resize its host's layout.
|
|
7
|
+
html:has(.lk-lab),
|
|
8
|
+
body:has(.lk-lab) {
|
|
9
|
+
margin: 0;
|
|
10
|
+
height: 100%;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body:has(.lk-lab) > :has(> .lk-lab) {
|
|
14
|
+
height: 100%;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
.lk-lab {
|
|
2
18
|
height: 100%;
|
|
3
19
|
}
|
package/src/lab/Lab.stories.tsx
CHANGED
|
@@ -31,21 +31,21 @@ export const Default: Story = {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
function
|
|
34
|
+
function AddSecondTrial() {
|
|
35
35
|
const ctx = useLabContext();
|
|
36
36
|
// biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
|
|
37
37
|
useEffect(() => {
|
|
38
|
-
if (ctx.
|
|
38
|
+
if (ctx.trials.length < 2) ctx.addTrial('Stub');
|
|
39
39
|
}, []);
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export const
|
|
43
|
+
export const TwoTrials: Story = {
|
|
44
44
|
args: {
|
|
45
45
|
instruments: [StubInstrument],
|
|
46
46
|
defaultInstrument: 'Stub',
|
|
47
|
-
title: 'Two
|
|
47
|
+
title: 'Two Trials',
|
|
48
48
|
storage: null,
|
|
49
|
-
children: <
|
|
49
|
+
children: <AddSecondTrial />,
|
|
50
50
|
},
|
|
51
51
|
};
|
package/src/lab/Lab.test.tsx
CHANGED
|
@@ -41,50 +41,50 @@ function mountLab(props: Partial<Parameters<typeof Lab>[0]> = {}) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe('<Lab>', () => {
|
|
44
|
-
it('renders one
|
|
44
|
+
it('renders one trial by default', () => {
|
|
45
45
|
mountLab();
|
|
46
|
-
expect(screen.getAllByRole('region', { name: /
|
|
46
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
it('
|
|
49
|
+
it('addTrial adds a second trial', () => {
|
|
50
50
|
mountLab();
|
|
51
|
-
act(() => labRef?.
|
|
52
|
-
expect(screen.getAllByRole('region', { name: /
|
|
51
|
+
act(() => labRef?.addTrial('Stub'));
|
|
52
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it('
|
|
55
|
+
it('closeTrial removes one when more than one exists', () => {
|
|
56
56
|
mountLab();
|
|
57
|
-
act(() => labRef?.
|
|
58
|
-
const first = labRef?.
|
|
59
|
-
act(() => labRef?.
|
|
60
|
-
expect(screen.getAllByRole('region', { name: /
|
|
57
|
+
act(() => labRef?.addTrial('Stub'));
|
|
58
|
+
const first = labRef?.trials[0];
|
|
59
|
+
act(() => labRef?.closeTrial(first?.id ?? ''));
|
|
60
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it('
|
|
63
|
+
it('closeTrial is a no-op on the last trial', () => {
|
|
64
64
|
mountLab();
|
|
65
|
-
const only = labRef?.
|
|
66
|
-
act(() => labRef?.
|
|
67
|
-
expect(screen.getAllByRole('region', { name: /
|
|
65
|
+
const only = labRef?.trials[0];
|
|
66
|
+
act(() => labRef?.closeTrial(only?.id ?? ''));
|
|
67
|
+
expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('
|
|
70
|
+
it('cloneTrial inserts immediately after source', () => {
|
|
71
71
|
mountLab();
|
|
72
|
-
act(() => labRef?.
|
|
73
|
-
const ws0 = labRef?.
|
|
74
|
-
const ws1 = labRef?.
|
|
75
|
-
act(() => labRef?.
|
|
76
|
-
const ids = labRef?.
|
|
72
|
+
act(() => labRef?.addTrial('StubB'));
|
|
73
|
+
const ws0 = labRef?.trials[0];
|
|
74
|
+
const ws1 = labRef?.trials[1];
|
|
75
|
+
act(() => labRef?.cloneTrial(ws0?.id ?? ''));
|
|
76
|
+
const ids = labRef?.trials.map((w) => w.id) ?? [];
|
|
77
77
|
expect(ids).toHaveLength(3);
|
|
78
78
|
expect(ids[0]).toBe(ws0?.id);
|
|
79
79
|
expect(ids[2]).toBe(ws1?.id);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('
|
|
82
|
+
it('resetTrial restores defaults', () => {
|
|
83
83
|
mountLab();
|
|
84
|
-
const ws = labRef?.
|
|
85
|
-
if (!ws) throw new Error('no
|
|
86
|
-
act(() => labRef?.
|
|
87
|
-
const reset = labRef?.
|
|
84
|
+
const ws = labRef?.trials[0];
|
|
85
|
+
if (!ws) throw new Error('no trial');
|
|
86
|
+
act(() => labRef?.resetTrial(ws.id));
|
|
87
|
+
const reset = labRef?.trials[0];
|
|
88
88
|
expect(reset?.config).toEqual({ count: 0 });
|
|
89
89
|
expect(reset?.state).toEqual({ value: 0 });
|
|
90
90
|
});
|
package/src/lab/Lab.tsx
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { ThemeProvider } from '@weasel-js/theme/react';
|
|
2
2
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { useStore } from 'zustand/react';
|
|
4
|
-
import type {
|
|
4
|
+
import type { InstrumentList } from '../instrument/types';
|
|
5
5
|
import { noneAdapter } from '../state/adapters';
|
|
6
6
|
import { LabStoreContext } from '../state/context';
|
|
7
7
|
import { createLabStore, type LabStore } from '../state/store';
|
|
8
|
-
import type { LabMode, StorageAdapter,
|
|
8
|
+
import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
|
|
9
9
|
import { interstellarTheme } from '../theme/interstellar';
|
|
10
|
-
import {
|
|
10
|
+
import { Trial } from '../trial/Trial';
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
addTrial as addTrialOp,
|
|
13
|
+
cloneTrial as cloneTrialOp,
|
|
14
|
+
closeTrial as closeTrialOp,
|
|
15
|
+
reorderTrials as reorderTrialsOp,
|
|
16
|
+
resetTrial as resetTrialOp,
|
|
17
|
+
} from '../trial/trialOps';
|
|
17
18
|
import { LabContext, type LabContextValue } from './LabContext';
|
|
18
19
|
import { LabShell } from './LabShell';
|
|
19
20
|
import { useResolvedMode } from './useSystemMode';
|
|
20
|
-
import {
|
|
21
|
+
import { type TrialLayout, Workspace } from './Workspace';
|
|
21
22
|
|
|
22
23
|
/** Props for `<Lab>`. */
|
|
23
24
|
export interface LabProps {
|
|
24
|
-
instruments:
|
|
25
|
+
instruments: InstrumentList;
|
|
25
26
|
defaultInstrument: string;
|
|
26
27
|
storage?: StorageAdapter | null;
|
|
27
28
|
storageKey?: string;
|
|
@@ -38,19 +39,19 @@ export interface LabProps {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function buildStore(
|
|
41
|
-
instruments:
|
|
42
|
+
instruments: InstrumentList,
|
|
42
43
|
defaultInstrument: string,
|
|
43
44
|
storage: StorageAdapter,
|
|
44
45
|
storageKey: string,
|
|
45
46
|
initialMode: LabMode,
|
|
46
47
|
): LabStore {
|
|
47
48
|
const store = createLabStore({ storageKey, storage, initialMode });
|
|
48
|
-
if (store.getState().
|
|
49
|
-
const seeded =
|
|
49
|
+
if (store.getState().trials.length === 0) {
|
|
50
|
+
const seeded = addTrialOp([], instruments, defaultInstrument);
|
|
50
51
|
const record = seeded[0];
|
|
51
52
|
if (record) {
|
|
52
53
|
const { undoStack: _undoStack, ...rest } = record;
|
|
53
|
-
store.getState().
|
|
54
|
+
store.getState().addTrial(rest);
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
return store;
|
|
@@ -70,15 +71,15 @@ const NEBULA_SLOTS = [
|
|
|
70
71
|
|
|
71
72
|
function buildNebula(colors: readonly string[]): string {
|
|
72
73
|
const blobs = colors.map((c, i) => {
|
|
73
|
-
const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length]
|
|
74
|
+
const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
|
|
74
75
|
return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
|
|
75
76
|
});
|
|
76
77
|
blobs.push('radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)');
|
|
77
78
|
return blobs.join(', ');
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/** The lab runtime: creates the store, provides it, and renders one
|
|
81
|
-
* per record in a grid. Each
|
|
81
|
+
/** The lab runtime: creates the store, provides it, and renders one trial
|
|
82
|
+
* per record in a grid. Each trial runs one of `instruments`. */
|
|
82
83
|
export function Lab({
|
|
83
84
|
instruments,
|
|
84
85
|
defaultInstrument,
|
|
@@ -105,9 +106,10 @@ export function Lab({
|
|
|
105
106
|
}
|
|
106
107
|
const store = storeRef.current;
|
|
107
108
|
|
|
108
|
-
const
|
|
109
|
+
const trials = useStore(store, (s) => s.trials);
|
|
109
110
|
const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
|
|
110
111
|
const modeValue = useStore(store, (s) => s.mode);
|
|
112
|
+
const layout = useStore(store, (s) => s.layout);
|
|
111
113
|
const resolvedMode = useResolvedMode(modeValue);
|
|
112
114
|
|
|
113
115
|
useEffect(() => {
|
|
@@ -117,48 +119,51 @@ export function Lab({
|
|
|
117
119
|
}, [mode, store]);
|
|
118
120
|
|
|
119
121
|
const contextValue = useMemo<LabContextValue>(() => {
|
|
120
|
-
const
|
|
121
|
-
const currentById = new Map(store.getState().
|
|
122
|
+
const replaceTrials = (next: TrialRecord[]): void => {
|
|
123
|
+
const currentById = new Map(store.getState().trials.map((w) => [w.id, w]));
|
|
122
124
|
const merged = next.map((w) => currentById.get(w.id) ?? w);
|
|
123
|
-
store.setState({
|
|
125
|
+
store.setState({ trials: merged });
|
|
124
126
|
// Trigger persistence flush via a tracked action.
|
|
125
127
|
store.getState().setMode(store.getState().mode);
|
|
126
128
|
};
|
|
127
129
|
|
|
128
130
|
return {
|
|
129
131
|
instruments,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const next =
|
|
133
|
-
|
|
132
|
+
trials,
|
|
133
|
+
addTrial: (instrumentName) => {
|
|
134
|
+
const next = addTrialOp(store.getState().trials, instruments, instrumentName);
|
|
135
|
+
replaceTrials(next);
|
|
134
136
|
},
|
|
135
|
-
|
|
136
|
-
const next =
|
|
137
|
-
|
|
137
|
+
cloneTrial: (id) => {
|
|
138
|
+
const next = cloneTrialOp(store.getState().trials, id);
|
|
139
|
+
replaceTrials(next);
|
|
138
140
|
},
|
|
139
|
-
|
|
140
|
-
const next =
|
|
141
|
-
|
|
141
|
+
closeTrial: (id) => {
|
|
142
|
+
const next = closeTrialOp(store.getState().trials, id);
|
|
143
|
+
replaceTrials(next);
|
|
142
144
|
},
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
reorderTrials: (ids) => {
|
|
146
|
+
replaceTrials(reorderTrialsOp(store.getState().trials, ids));
|
|
147
|
+
},
|
|
148
|
+
resetTrial: (id) => {
|
|
149
|
+
const next = resetTrialOp(store.getState().trials, id, instruments);
|
|
145
150
|
const record = next.find((w) => w.id === id);
|
|
146
151
|
if (!record) return;
|
|
147
152
|
store.setState((s) => ({
|
|
148
|
-
|
|
153
|
+
trials: s.trials.map((w) =>
|
|
149
154
|
w.id === id
|
|
150
155
|
? { ...w, config: record.config, state: record.state, view: record.view }
|
|
151
156
|
: w,
|
|
152
157
|
),
|
|
153
158
|
}));
|
|
154
|
-
store.getState().
|
|
159
|
+
store.getState().updateTrialView(id, record.view);
|
|
155
160
|
},
|
|
156
161
|
savedSnapshots,
|
|
157
|
-
saveSnapshot: (
|
|
158
|
-
store.getState().saveSnapshot(
|
|
162
|
+
saveSnapshot: (trialId, name) => {
|
|
163
|
+
store.getState().saveSnapshot(trialId, name ?? `Save ${new Date().toLocaleString()}`);
|
|
159
164
|
},
|
|
160
|
-
loadSnapshot: (
|
|
161
|
-
store.getState().loadSnapshot(snapshotId,
|
|
165
|
+
loadSnapshot: (trialId, snapshotId) => {
|
|
166
|
+
store.getState().loadSnapshot(snapshotId, trialId);
|
|
162
167
|
},
|
|
163
168
|
deleteSnapshot: (snapshotId) => {
|
|
164
169
|
store.getState().deleteSnapshot(snapshotId);
|
|
@@ -168,7 +173,7 @@ export function Lab({
|
|
|
168
173
|
store.getState().setMode(m);
|
|
169
174
|
},
|
|
170
175
|
};
|
|
171
|
-
}, [instruments,
|
|
176
|
+
}, [instruments, trials, savedSnapshots, modeValue, store]);
|
|
172
177
|
|
|
173
178
|
// Only override the backdrop in dark, where there is one to override.
|
|
174
179
|
// Setting a CSS custom property is the sanctioned use of inline style.
|
|
@@ -187,11 +192,18 @@ export function Lab({
|
|
|
187
192
|
style={backdropStyle}
|
|
188
193
|
>
|
|
189
194
|
<LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
|
|
190
|
-
<
|
|
191
|
-
{
|
|
192
|
-
|
|
195
|
+
<Workspace
|
|
196
|
+
ids={trials.map((w) => w.id)}
|
|
197
|
+
resizable
|
|
198
|
+
reorderable
|
|
199
|
+
onReorder={(ids) => contextValue.reorderTrials(ids)}
|
|
200
|
+
layout={layout as TrialLayout}
|
|
201
|
+
onLayoutChange={(next) => store.getState().setLayout(next)}
|
|
202
|
+
>
|
|
203
|
+
{trials.map((w) => (
|
|
204
|
+
<Trial key={w.id} id={w.id} />
|
|
193
205
|
))}
|
|
194
|
-
</
|
|
206
|
+
</Workspace>
|
|
195
207
|
</LabShell>
|
|
196
208
|
</ThemeProvider>
|
|
197
209
|
</LabContext.Provider>
|
package/src/lab/LabContext.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
|
-
import type {
|
|
3
|
-
import type { LabMode, SavedSnapshot,
|
|
2
|
+
import type { InstrumentList } from '../instrument/types';
|
|
3
|
+
import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
|
|
4
4
|
|
|
5
5
|
/** Lab-wide state and commands: the available instruments, the open
|
|
6
|
-
*
|
|
6
|
+
* trials and the operations over them, saved snapshots, and the color
|
|
7
7
|
* mode. */
|
|
8
8
|
export interface LabContextValue {
|
|
9
|
-
instruments:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
instruments: InstrumentList;
|
|
10
|
+
trials: TrialRecord[];
|
|
11
|
+
addTrial: (instrumentName: string) => void;
|
|
12
|
+
cloneTrial: (id: string) => void;
|
|
13
|
+
closeTrial: (id: string) => void;
|
|
14
|
+
resetTrial: (id: string) => void;
|
|
15
|
+
reorderTrials: (ids: readonly string[]) => void;
|
|
15
16
|
savedSnapshots: SavedSnapshot[];
|
|
16
|
-
saveSnapshot: (
|
|
17
|
-
loadSnapshot: (
|
|
17
|
+
saveSnapshot: (trialId: string, name?: string) => void;
|
|
18
|
+
loadSnapshot: (trialId: string, snapshotId: string) => void;
|
|
18
19
|
deleteSnapshot: (snapshotId: string) => void;
|
|
19
20
|
mode: LabMode;
|
|
20
21
|
setMode: (m: LabMode) => void;
|
package/src/lab/LabShell.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export interface LabShellProps {
|
|
|
18
18
|
|
|
19
19
|
/** Page frame for a lab: a titled header, a body, and an optional footer,
|
|
20
20
|
* themed for the resolved color mode. Presentational only — use `<Lab>` when
|
|
21
|
-
* the
|
|
21
|
+
* the trial runtime is wanted too. */
|
|
22
22
|
export function LabShell({ title, children, header, footer, mode = 'auto' }: LabShellProps) {
|
|
23
23
|
const resolved = useResolvedMode(mode);
|
|
24
24
|
const outer = useThemeOptional();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Tiles are positioned by windease at strategy-computed rects; `.windease-zone`
|
|
2
|
+
// (from windease/styles.css) supplies the containing block and the 100% box.
|
|
3
|
+
// This class exists for consumers to target.
|
|
4
|
+
.lk-workspace {
|
|
5
|
+
min-height: 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Only rendered when `reorderable` — a tile is full of controls, so the drag
|
|
9
|
+
// target has to be its own strip rather than the whole pane.
|
|
10
|
+
.lk-trial-tile {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
height: 100%;
|
|
14
|
+
|
|
15
|
+
&__grip {
|
|
16
|
+
flex: 0 0 auto;
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
height: 14px;
|
|
21
|
+
cursor: grab;
|
|
22
|
+
border-radius: var(--wzl-radius-sm) var(--wzl-radius-sm) 0 0;
|
|
23
|
+
background: var(--wzl-surface-sunken);
|
|
24
|
+
|
|
25
|
+
&:active { cursor: grabbing; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
&__grip-dots {
|
|
29
|
+
width: 28px;
|
|
30
|
+
height: 3px;
|
|
31
|
+
border-radius: 999px;
|
|
32
|
+
background: var(--wzl-fg-muted);
|
|
33
|
+
opacity: 0.5;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&__body {
|
|
37
|
+
flex: 1;
|
|
38
|
+
min-height: 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
-
import {
|
|
2
|
+
import { Workspace } from './Workspace';
|
|
3
3
|
|
|
4
|
-
const meta: Meta<typeof
|
|
5
|
-
title: 'labkit/Lab/
|
|
6
|
-
component:
|
|
4
|
+
const meta: Meta<typeof Workspace> = {
|
|
5
|
+
title: 'labkit/Lab/Workspace',
|
|
6
|
+
component: Workspace,
|
|
7
7
|
};
|
|
8
8
|
export default meta;
|
|
9
9
|
|
|
10
|
-
type Story = StoryObj<typeof
|
|
10
|
+
type Story = StoryObj<typeof Workspace>;
|
|
11
11
|
|
|
12
12
|
const Tile = ({ children }: { children: React.ReactNode }) => (
|
|
13
13
|
<div
|
|
@@ -28,9 +28,9 @@ const Tile = ({ children }: { children: React.ReactNode }) => (
|
|
|
28
28
|
export const OneTile: Story = {
|
|
29
29
|
render: () => (
|
|
30
30
|
<div style={{ height: 400 }}>
|
|
31
|
-
<
|
|
31
|
+
<Workspace>
|
|
32
32
|
<Tile>1</Tile>
|
|
33
|
-
</
|
|
33
|
+
</Workspace>
|
|
34
34
|
</div>
|
|
35
35
|
),
|
|
36
36
|
};
|
|
@@ -38,11 +38,11 @@ export const OneTile: Story = {
|
|
|
38
38
|
export const ThreeTiles: Story = {
|
|
39
39
|
render: () => (
|
|
40
40
|
<div style={{ height: 400 }}>
|
|
41
|
-
<
|
|
41
|
+
<Workspace>
|
|
42
42
|
<Tile>1</Tile>
|
|
43
43
|
<Tile>2</Tile>
|
|
44
44
|
<Tile>3</Tile>
|
|
45
|
-
</
|
|
45
|
+
</Workspace>
|
|
46
46
|
</div>
|
|
47
47
|
),
|
|
48
48
|
};
|
|
@@ -50,12 +50,28 @@ export const ThreeTiles: Story = {
|
|
|
50
50
|
export const SevenTiles: Story = {
|
|
51
51
|
render: () => (
|
|
52
52
|
<div style={{ height: 600 }}>
|
|
53
|
-
<
|
|
53
|
+
<Workspace>
|
|
54
54
|
{Array.from({ length: 7 }).map((_, i) => (
|
|
55
55
|
// biome-ignore lint/suspicious/noArrayIndexKey: static demo list
|
|
56
56
|
<Tile key={i}>{i + 1}</Tile>
|
|
57
57
|
))}
|
|
58
|
-
</
|
|
58
|
+
</Workspace>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Draggable seams. Drag a tile edge, or Tab to a seam and press an arrow —
|
|
64
|
+
* a grid moves extents a whole cell at a time. */
|
|
65
|
+
export const Resizable: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<div style={{ height: 500 }}>
|
|
68
|
+
<Workspace ids={['a', 'b', 'c', 'd', 'e']} resizable>
|
|
69
|
+
<Tile>1</Tile>
|
|
70
|
+
<Tile>2</Tile>
|
|
71
|
+
<Tile>3</Tile>
|
|
72
|
+
<Tile>4</Tile>
|
|
73
|
+
<Tile>5</Tile>
|
|
74
|
+
</Workspace>
|
|
59
75
|
</div>
|
|
60
76
|
),
|
|
61
77
|
};
|