@weasel-js/labkit 1.0.4 → 1.2.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 +33 -6
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
- package/dist/canvas/index.d.ts +2 -1
- package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
- package/dist/chunk-73KA7WBO.js.map +1 -0
- package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
- package/dist/chunk-BOHF3PQO.js.map +1 -0
- package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
- package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/chunk-THBG7FQZ.js +167 -0
- package/dist/chunk-THBG7FQZ.js.map +1 -0
- package/dist/chunk-VUU5UXHE.js +491 -0
- package/dist/chunk-VUU5UXHE.js.map +1 -0
- package/dist/dragdrop/index.d.ts +2 -1
- package/dist/index.d.ts +211 -151
- package/dist/index.js +317 -169
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +3 -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 +29 -6
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +12 -12
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +31 -13
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +3 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/canvas/AGENTS.md +14 -2
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +70 -0
- package/src/index.ts +24 -7
- package/src/instrument/SineWave.smoke.test.tsx +2 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +30 -6
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +6 -7
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +50 -50
- package/src/lab/LabContext.ts +12 -12
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
- package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
- package/src/lab/index.ts +2 -2
- 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 +21 -29
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -41
- package/src/state/store.ts +165 -102
- package/src/state/types.ts +32 -13
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +2 -2
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +157 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/trial/DefaultStatusBar.tsx +19 -0
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/trial/Trial.job.test.tsx +75 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
- package/src/{workspace → trial}/index.ts +13 -13
- package/src/{workspace → trial}/slotTypes.ts +18 -17
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
- 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/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
- package/dist/chunk-BAPZPDDA.js.map +0 -1
- package/dist/chunk-DWV7SFKR.js.map +0 -1
- package/dist/chunk-KSTEW2AF.js +0 -367
- package/dist/chunk-KSTEW2AF.js.map +0 -1
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/DefaultStatusBar.tsx +0 -18
- package/src/workspace/workspaceOps.ts +0 -102
package/src/lab/LabContext.ts
CHANGED
|
@@ -1,21 +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
|
-
|
|
15
|
-
|
|
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;
|
|
16
16
|
savedSnapshots: SavedSnapshot[];
|
|
17
|
-
saveSnapshot: (
|
|
18
|
-
loadSnapshot: (
|
|
17
|
+
saveSnapshot: (trialId: string, name?: string) => void;
|
|
18
|
+
loadSnapshot: (trialId: string, snapshotId: string) => void;
|
|
19
19
|
deleteSnapshot: (snapshotId: string) => void;
|
|
20
20
|
mode: LabMode;
|
|
21
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();
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Tiles are positioned by windease at strategy-computed rects; `.windease-zone`
|
|
2
2
|
// (from windease/styles.css) supplies the containing block and the 100% box.
|
|
3
3
|
// This class exists for consumers to target.
|
|
4
|
-
.lk-workspace
|
|
4
|
+
.lk-workspace {
|
|
5
5
|
min-height: 0;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
// Only rendered when `reorderable` — a tile is full of controls, so the drag
|
|
9
9
|
// target has to be its own strip rather than the whole pane.
|
|
10
|
-
.lk-
|
|
10
|
+
.lk-trial-tile {
|
|
11
11
|
display: flex;
|
|
12
12
|
flex-direction: column;
|
|
13
13
|
height: 100%;
|
|
@@ -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,12 @@ 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
59
|
</div>
|
|
60
60
|
),
|
|
61
61
|
};
|
|
@@ -65,13 +65,13 @@ export const SevenTiles: Story = {
|
|
|
65
65
|
export const Resizable: Story = {
|
|
66
66
|
render: () => (
|
|
67
67
|
<div style={{ height: 500 }}>
|
|
68
|
-
<
|
|
68
|
+
<Workspace ids={['a', 'b', 'c', 'd', 'e']} resizable>
|
|
69
69
|
<Tile>1</Tile>
|
|
70
70
|
<Tile>2</Tile>
|
|
71
71
|
<Tile>3</Tile>
|
|
72
72
|
<Tile>4</Tile>
|
|
73
73
|
<Tile>5</Tile>
|
|
74
|
-
</
|
|
74
|
+
</Workspace>
|
|
75
75
|
</div>
|
|
76
76
|
),
|
|
77
77
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { SurfaceContext } from '../surface/SurfaceContext';
|
|
4
|
+
import type { SurfaceHandle } from '../surface/useTiledSurface';
|
|
5
|
+
import { Workspace } from './Workspace';
|
|
6
|
+
|
|
7
|
+
function fakeHandle(): SurfaceHandle {
|
|
8
|
+
return {
|
|
9
|
+
invalidate: vi.fn(),
|
|
10
|
+
invalidateAll: vi.fn(),
|
|
11
|
+
invalidateRects: vi.fn(),
|
|
12
|
+
registerTile: vi.fn(),
|
|
13
|
+
containerRef: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Workspace with a surface above it', () => {
|
|
18
|
+
it('invalidates rects when the tile set changes, because a re-tile moves tiles', () => {
|
|
19
|
+
const handle = fakeHandle();
|
|
20
|
+
const { rerender } = render(
|
|
21
|
+
<SurfaceContext.Provider value={handle}>
|
|
22
|
+
<Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
|
|
23
|
+
<div>a</div>
|
|
24
|
+
</Workspace>
|
|
25
|
+
</SurfaceContext.Provider>,
|
|
26
|
+
);
|
|
27
|
+
(handle.invalidateRects as ReturnType<typeof vi.fn>).mockClear();
|
|
28
|
+
|
|
29
|
+
rerender(
|
|
30
|
+
<SurfaceContext.Provider value={handle}>
|
|
31
|
+
<Workspace ids={['a', 'b']} viewport={{ w: 800, h: 600 }}>
|
|
32
|
+
<div>a</div>
|
|
33
|
+
<div>b</div>
|
|
34
|
+
</Workspace>
|
|
35
|
+
</SurfaceContext.Provider>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(handle.invalidateRects).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders unchanged with no surface above it', () => {
|
|
42
|
+
const { getByText } = render(
|
|
43
|
+
<Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
|
|
44
|
+
<div>a</div>
|
|
45
|
+
</Workspace>,
|
|
46
|
+
);
|
|
47
|
+
expect(getByText('a')).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { Workspace } from './Workspace';
|
|
4
4
|
|
|
5
5
|
const VIEWPORT = { w: 800, h: 600 };
|
|
6
6
|
|
|
7
|
-
describe('
|
|
7
|
+
describe('Workspace', () => {
|
|
8
8
|
test('renders all children', () => {
|
|
9
9
|
render(
|
|
10
|
-
<
|
|
10
|
+
<Workspace viewport={VIEWPORT}>
|
|
11
11
|
<div>one</div>
|
|
12
12
|
<div>two</div>
|
|
13
13
|
<div>three</div>
|
|
14
|
-
</
|
|
14
|
+
</Workspace>,
|
|
15
15
|
);
|
|
16
16
|
expect(screen.getByText('one')).toBeInTheDocument();
|
|
17
17
|
expect(screen.getByText('two')).toBeInTheDocument();
|
|
@@ -21,54 +21,54 @@ describe('WorkspaceGrid', () => {
|
|
|
21
21
|
test('keeps a child with the tile its id names when an earlier one closes', () => {
|
|
22
22
|
const ids = ['a', 'b', 'c'];
|
|
23
23
|
const { rerender, container } = render(
|
|
24
|
-
<
|
|
24
|
+
<Workspace ids={ids} viewport={VIEWPORT}>
|
|
25
25
|
<div>a</div>
|
|
26
26
|
<div>b</div>
|
|
27
27
|
<div>c</div>
|
|
28
|
-
</
|
|
28
|
+
</Workspace>,
|
|
29
29
|
);
|
|
30
30
|
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
31
31
|
|
|
32
32
|
rerender(
|
|
33
|
-
<
|
|
33
|
+
<Workspace ids={['b', 'c']} viewport={VIEWPORT}>
|
|
34
34
|
<div>b</div>
|
|
35
35
|
<div>c</div>
|
|
36
|
-
</
|
|
36
|
+
</Workspace>,
|
|
37
37
|
);
|
|
38
38
|
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
39
39
|
expect(container.querySelector('[data-node="a"]')).toBeNull();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
test('uses lk-workspace
|
|
42
|
+
test('uses lk-workspace class', () => {
|
|
43
43
|
const { container } = render(
|
|
44
|
-
<
|
|
44
|
+
<Workspace viewport={VIEWPORT}>
|
|
45
45
|
<div />
|
|
46
|
-
</
|
|
46
|
+
</Workspace>,
|
|
47
47
|
);
|
|
48
|
-
expect((container.firstChild as HTMLElement).className).toContain('lk-workspace
|
|
48
|
+
expect((container.firstChild as HTMLElement).className).toContain('lk-workspace');
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
test('renders resize affordances only when resizable', () => {
|
|
52
52
|
const { container, rerender } = render(
|
|
53
|
-
<
|
|
53
|
+
<Workspace ids={['a', 'b']} viewport={VIEWPORT}>
|
|
54
54
|
<div>a</div>
|
|
55
55
|
<div>b</div>
|
|
56
|
-
</
|
|
56
|
+
</Workspace>,
|
|
57
57
|
);
|
|
58
58
|
expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
|
|
59
59
|
|
|
60
60
|
rerender(
|
|
61
|
-
<
|
|
61
|
+
<Workspace ids={['a', 'b']} resizable viewport={VIEWPORT}>
|
|
62
62
|
<div>a</div>
|
|
63
63
|
<div>b</div>
|
|
64
|
-
</
|
|
64
|
+
</Workspace>,
|
|
65
65
|
);
|
|
66
66
|
expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
test('applies a saved extent to a tile as it registers', () => {
|
|
70
70
|
const { container } = render(
|
|
71
|
-
<
|
|
71
|
+
<Workspace
|
|
72
72
|
ids={['a', 'b']}
|
|
73
73
|
resizable
|
|
74
74
|
viewport={VIEWPORT}
|
|
@@ -76,7 +76,7 @@ describe('WorkspaceGrid', () => {
|
|
|
76
76
|
>
|
|
77
77
|
<div>a</div>
|
|
78
78
|
<div>b</div>
|
|
79
|
-
</
|
|
79
|
+
</Workspace>,
|
|
80
80
|
);
|
|
81
81
|
const a = container.querySelector('[data-node="a"]') as HTMLElement;
|
|
82
82
|
const b = container.querySelector('[data-node="b"]') as HTMLElement;
|
|
@@ -87,14 +87,14 @@ describe('WorkspaceGrid', () => {
|
|
|
87
87
|
test('reports the order a drop would produce instead of applying it', () => {
|
|
88
88
|
const onReorder = vi.fn();
|
|
89
89
|
const { container } = render(
|
|
90
|
-
<
|
|
90
|
+
<Workspace ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
|
|
91
91
|
<div>a</div>
|
|
92
92
|
<div>b</div>
|
|
93
|
-
</
|
|
93
|
+
</Workspace>,
|
|
94
94
|
);
|
|
95
95
|
// The grid is controlled: it renders a handle per tile and commits nothing
|
|
96
96
|
// itself. Order still comes from `ids`.
|
|
97
|
-
expect(container.querySelectorAll('.lk-
|
|
97
|
+
expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(2);
|
|
98
98
|
expect(onReorder).not.toHaveBeenCalled();
|
|
99
99
|
const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
|
|
100
100
|
el.getAttribute('data-node'),
|
|
@@ -104,11 +104,11 @@ describe('WorkspaceGrid', () => {
|
|
|
104
104
|
|
|
105
105
|
test('renders no drag handles unless reorderable', () => {
|
|
106
106
|
const { container } = render(
|
|
107
|
-
<
|
|
107
|
+
<Workspace ids={['a', 'b']} viewport={VIEWPORT}>
|
|
108
108
|
<div>a</div>
|
|
109
109
|
<div>b</div>
|
|
110
|
-
</
|
|
110
|
+
</Workspace>,
|
|
111
111
|
);
|
|
112
|
-
expect(container.querySelectorAll('.lk-
|
|
112
|
+
expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(0);
|
|
113
113
|
});
|
|
114
114
|
});
|
|
@@ -17,18 +17,20 @@ import {
|
|
|
17
17
|
StrategyRegistryProvider,
|
|
18
18
|
} from 'windease/react';
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
import { useSurfaceOptional } from '../surface/useSurfaceTile';
|
|
21
|
+
|
|
22
|
+
const ZONE_ID = asNodeId('lk-workspace');
|
|
21
23
|
const STRATEGIES = { grid: gridStrategy as never };
|
|
22
|
-
const KIND = '
|
|
24
|
+
const KIND = 'trial';
|
|
23
25
|
|
|
24
26
|
/** A tile's persisted extent, keyed by the id its caller gave it. Grid resizes
|
|
25
27
|
* write `span`; `size` is here because a strategy swap would write that. */
|
|
26
|
-
export type
|
|
28
|
+
export type TrialLayout = Record<
|
|
27
29
|
string,
|
|
28
30
|
{ size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
|
|
29
31
|
>;
|
|
30
32
|
|
|
31
|
-
export interface
|
|
33
|
+
export interface WorkspaceProps {
|
|
32
34
|
children: ReactNode;
|
|
33
35
|
/**
|
|
34
36
|
* Stable id per child, positionally matched. Supply these whenever a tile
|
|
@@ -48,10 +50,10 @@ export interface WorkspaceGridProps {
|
|
|
48
50
|
/** The full id list a drop would produce, in its new order. */
|
|
49
51
|
onReorder?: (ids: string[]) => void;
|
|
50
52
|
/** Extents from a previous session, applied to tiles as they register. */
|
|
51
|
-
layout?:
|
|
53
|
+
layout?: TrialLayout;
|
|
52
54
|
/** Fires when a tile's extent changes. Persist it and hand it back as
|
|
53
55
|
* `layout` to make a resize survive a reload. */
|
|
54
|
-
onLayoutChange?: (layout:
|
|
56
|
+
onLayoutChange?: (layout: TrialLayout) => void;
|
|
55
57
|
gap?: number;
|
|
56
58
|
padding?: number;
|
|
57
59
|
/**
|
|
@@ -62,23 +64,23 @@ export interface WorkspaceGridProps {
|
|
|
62
64
|
viewport?: { w: number; h: number };
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
function extentOf(store: Store, id: NodeId):
|
|
66
|
-
const p = store.getNode(id)?.membership?.placement as
|
|
67
|
+
function extentOf(store: Store, id: NodeId): TrialLayout[string] | null {
|
|
68
|
+
const p = store.getNode(id)?.membership?.placement as TrialLayout[string] | undefined;
|
|
67
69
|
if (!p) return null;
|
|
68
|
-
const out:
|
|
70
|
+
const out: TrialLayout[string] = {};
|
|
69
71
|
if (p.size) out.size = p.size;
|
|
70
72
|
if (p.span) out.span = p.span;
|
|
71
73
|
return Object.keys(out).length > 0 ? out : null;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
|
75
|
-
* Auto-balanced tiling of
|
|
77
|
+
* Auto-balanced tiling of trials, `ceil(sqrt(n))` columns wide.
|
|
76
78
|
*
|
|
77
79
|
* Tiles are absolutely positioned at the rects `gridStrategy` computes, not
|
|
78
80
|
* laid out by CSS — `windease/styles.css` (folded into
|
|
79
81
|
* `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
|
|
80
82
|
*/
|
|
81
|
-
export function
|
|
83
|
+
export function Workspace({
|
|
82
84
|
children,
|
|
83
85
|
ids,
|
|
84
86
|
resizable = false,
|
|
@@ -89,11 +91,11 @@ export function WorkspaceGrid({
|
|
|
89
91
|
gap = 12,
|
|
90
92
|
padding = 0,
|
|
91
93
|
viewport,
|
|
92
|
-
}:
|
|
94
|
+
}: WorkspaceProps) {
|
|
93
95
|
const items = Children.toArray(children);
|
|
94
96
|
const idKey = ids ? ids.join(',') : `#${items.length}`;
|
|
95
97
|
// biome-ignore lint/correctness/useExhaustiveDependencies: idKey is the stable projection of items/ids; depending on those directly rebuilds every render and re-runs the sync effect forever
|
|
96
|
-
const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]);
|
|
98
|
+
const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
97
99
|
|
|
98
100
|
// Held in refs rather than depended on: a fresh object each render would
|
|
99
101
|
// re-run the sync effect, and only a newly registered tile reads `layout`.
|
|
@@ -119,6 +121,10 @@ export function WorkspaceGrid({
|
|
|
119
121
|
}
|
|
120
122
|
const store = storeRef.current;
|
|
121
123
|
|
|
124
|
+
// A tile that only moves reports nothing to a ResizeObserver, and only this
|
|
125
|
+
// component knows the grid moved one. Optional: a lab may own no surface.
|
|
126
|
+
const surface = useSurfaceOptional();
|
|
127
|
+
|
|
122
128
|
useLayoutEffect(() => {
|
|
123
129
|
store.updateContainerConfig(ZONE_ID, { resizable, gap, padding });
|
|
124
130
|
}, [store, resizable, gap, padding]);
|
|
@@ -139,10 +145,17 @@ export function WorkspaceGrid({
|
|
|
139
145
|
store.setChildOrder(ZONE_ID, [...nodeIds]);
|
|
140
146
|
}, [store, nodeIds]);
|
|
141
147
|
|
|
148
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: nodeIds is the signal that the tile set changed — a re-tile moves tiles without resizing any — not a value this reads
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!surface) return;
|
|
151
|
+
surface.invalidateRects();
|
|
152
|
+
return store.events.on('node.placementChanged', () => surface.invalidateRects());
|
|
153
|
+
}, [store, surface, nodeIds]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
154
|
+
|
|
142
155
|
useEffect(() => {
|
|
143
156
|
if (!onLayoutChange) return;
|
|
144
157
|
return store.events.on('node.placementChanged', () => {
|
|
145
|
-
const next:
|
|
158
|
+
const next: TrialLayout = {};
|
|
146
159
|
for (const child of store.getChildren(ZONE_ID)) {
|
|
147
160
|
const extent = extentOf(store, child.id);
|
|
148
161
|
if (extent) next[child.id] = extent;
|
|
@@ -164,11 +177,11 @@ export function WorkspaceGrid({
|
|
|
164
177
|
if (!reorderable) return content;
|
|
165
178
|
// A tile is full of controls, so the whole thing can't be the handle.
|
|
166
179
|
return (
|
|
167
|
-
<div className="lk-
|
|
168
|
-
<DragHandle nodeId={node.id} className="lk-
|
|
169
|
-
<span className="lk-
|
|
180
|
+
<div className="lk-trial-tile">
|
|
181
|
+
<DragHandle nodeId={node.id} className="lk-trial-tile__grip">
|
|
182
|
+
<span className="lk-trial-tile__grip-dots" aria-hidden="true" />
|
|
170
183
|
</DragHandle>
|
|
171
|
-
<div className="lk-
|
|
184
|
+
<div className="lk-trial-tile__body">{content}</div>
|
|
172
185
|
</div>
|
|
173
186
|
);
|
|
174
187
|
},
|
|
@@ -179,7 +192,7 @@ export function WorkspaceGrid({
|
|
|
179
192
|
<Container
|
|
180
193
|
parentId={ZONE_ID}
|
|
181
194
|
chrome={chrome}
|
|
182
|
-
className="lk-workspace
|
|
195
|
+
className="lk-workspace windease-zone"
|
|
183
196
|
affordances={resizable}
|
|
184
197
|
viewport={viewport}
|
|
185
198
|
onChildOrderChange={reorderable ? commitOrder : undefined}
|
package/src/lab/index.ts
CHANGED
|
@@ -4,5 +4,5 @@ export type { LabContextValue } from './LabContext';
|
|
|
4
4
|
export { LabContext, useLabContext } from './LabContext';
|
|
5
5
|
export type { LabShellProps } from './LabShell';
|
|
6
6
|
export { LabShell } from './LabShell';
|
|
7
|
-
export type {
|
|
8
|
-
export {
|
|
7
|
+
export type { WorkspaceProps } from './Workspace';
|
|
8
|
+
export { Workspace } from './Workspace';
|
package/src/layers/AGENTS.md
CHANGED
|
@@ -31,9 +31,9 @@ The instrument declares layer ids:
|
|
|
31
31
|
layers: { ids: ['grid', 'plants'] }
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`
|
|
34
|
+
`Trial.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
|
|
35
35
|
|
|
36
|
-
There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the
|
|
36
|
+
There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the trial assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
|
|
37
37
|
|
|
38
38
|
## `alwaysOn` semantics
|
|
39
39
|
|
|
@@ -52,7 +52,7 @@ Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dra
|
|
|
52
52
|
|
|
53
53
|
## Empty state
|
|
54
54
|
|
|
55
|
-
If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers".
|
|
55
|
+
If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Trial already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
|
|
56
56
|
|
|
57
57
|
## When to fork
|
|
58
58
|
|
|
@@ -12,7 +12,7 @@ type Story = StoryObj<typeof Toolbar>;
|
|
|
12
12
|
export const Default: Story = {
|
|
13
13
|
render: () => (
|
|
14
14
|
<Toolbar>
|
|
15
|
-
<Toolbar.Title>My
|
|
15
|
+
<Toolbar.Title>My Trial</Toolbar.Title>
|
|
16
16
|
<Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
|
|
17
17
|
<Toolbar.Button onClick={() => {}}>Redo</Toolbar.Button>
|
|
18
18
|
<Toolbar.Spacer />
|
|
@@ -24,7 +24,7 @@ export const Default: Story = {
|
|
|
24
24
|
export const WithDisabled: Story = {
|
|
25
25
|
render: () => (
|
|
26
26
|
<Toolbar>
|
|
27
|
-
<Toolbar.Title>Empty
|
|
27
|
+
<Toolbar.Title>Empty trial</Toolbar.Title>
|
|
28
28
|
<Toolbar.Button onClick={() => {}} disabled>
|
|
29
29
|
Undo
|
|
30
30
|
</Toolbar.Button>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { act, render, renderHook } from '@testing-library/react';
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { createMemoryAdapter } from './adapters';
|
|
4
|
+
import { labDocumentKey } from './document';
|
|
4
5
|
import { SingletonExperimentProvider } from './SingletonExperiment';
|
|
5
|
-
import {
|
|
6
|
+
import { useTrialState } from './useTrialState';
|
|
6
7
|
|
|
7
8
|
interface Config {
|
|
8
9
|
width: number;
|
|
@@ -17,7 +18,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
17
18
|
vi.useRealTimers();
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
it('exposes config and state via
|
|
21
|
+
it('exposes config and state via useTrialState', () => {
|
|
21
22
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
22
23
|
<SingletonExperimentProvider<State, Config>
|
|
23
24
|
id="test"
|
|
@@ -29,7 +30,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
29
30
|
{children}
|
|
30
31
|
</SingletonExperimentProvider>
|
|
31
32
|
);
|
|
32
|
-
const { result } = renderHook(() =>
|
|
33
|
+
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
33
34
|
expect(result.current.config).toEqual({ width: 100, bg: '#000' });
|
|
34
35
|
expect(result.current.state).toEqual({ zoom: 1 });
|
|
35
36
|
});
|
|
@@ -38,7 +39,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
38
39
|
vi.useFakeTimers();
|
|
39
40
|
const storage = createMemoryAdapter();
|
|
40
41
|
const Probe = () => {
|
|
41
|
-
const h =
|
|
42
|
+
const h = useTrialState<State, Config>();
|
|
42
43
|
return (
|
|
43
44
|
<button type="button" onClick={() => h.setConfig('width', 200)}>
|
|
44
45
|
go
|
|
@@ -62,15 +63,14 @@ describe('SingletonExperimentProvider', () => {
|
|
|
62
63
|
act(() => {
|
|
63
64
|
vi.advanceTimersByTime(400);
|
|
64
65
|
});
|
|
65
|
-
|
|
66
|
-
const raw = storage.read('lk:test:workspaces');
|
|
66
|
+
const raw = storage.read(labDocumentKey('test'));
|
|
67
67
|
expect(raw).toBeTruthy();
|
|
68
68
|
expect(raw).toContain('"width":200');
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
it('rehydrates from storage on mount', () => {
|
|
72
72
|
const storage = createMemoryAdapter();
|
|
73
|
-
//
|
|
73
|
+
// A pre-document lab, which createLabStore folds forward on hydration.
|
|
74
74
|
storage.write(
|
|
75
75
|
'lk:test:workspaces',
|
|
76
76
|
JSON.stringify([
|
|
@@ -94,7 +94,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
94
94
|
{children}
|
|
95
95
|
</SingletonExperimentProvider>
|
|
96
96
|
);
|
|
97
|
-
const { result } = renderHook(() =>
|
|
97
|
+
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
98
98
|
expect(result.current.config).toEqual({ width: 999, bg: '#fff' });
|
|
99
99
|
expect(result.current.state).toEqual({ zoom: 2 });
|
|
100
100
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode, useRef } from 'react';
|
|
2
|
-
import { LabStoreProvider,
|
|
2
|
+
import { LabStoreProvider, TrialIdProvider } from './context';
|
|
3
3
|
import { createLabStore, type LabStore } from './store';
|
|
4
4
|
import type { StorageAdapter } from './types';
|
|
5
5
|
|
|
@@ -7,8 +7,8 @@ const SINGLETON_INSTRUMENT = '__singleton__';
|
|
|
7
7
|
|
|
8
8
|
/** Props for `<SingletonExperimentProvider>`. */
|
|
9
9
|
export interface SingletonExperimentProviderProps<TS, TC> {
|
|
10
|
-
/** Stable id for the synthetic
|
|
11
|
-
*
|
|
10
|
+
/** Stable id for the synthetic trial; also doubles as the
|
|
11
|
+
* TrialIdContext value. */
|
|
12
12
|
id: string;
|
|
13
13
|
initialConfig: TC;
|
|
14
14
|
initialState: TS;
|
|
@@ -18,9 +18,9 @@ export interface SingletonExperimentProviderProps<TS, TC> {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* One-
|
|
22
|
-
* a `LabStoreProvider` + `
|
|
23
|
-
*
|
|
21
|
+
* One-trial `<Lab>` substitute for single-screen experiments. Mounts
|
|
22
|
+
* a `LabStoreProvider` + `TrialIdProvider` with one synthetic
|
|
23
|
+
* trial, so `useTrialState` works without going through the
|
|
24
24
|
* full `<Lab instruments={...}>` runtime.
|
|
25
25
|
*/
|
|
26
26
|
export function SingletonExperimentProvider<TS, TC>({
|
|
@@ -34,8 +34,8 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
34
34
|
const storeRef = useRef<LabStore | null>(null);
|
|
35
35
|
if (storeRef.current === null) {
|
|
36
36
|
const store = createLabStore({ storageKey, storage });
|
|
37
|
-
if (!store.getState().
|
|
38
|
-
store.getState().
|
|
37
|
+
if (!store.getState().trials.some((w) => w.id === id)) {
|
|
38
|
+
store.getState().addTrial({
|
|
39
39
|
id,
|
|
40
40
|
instrumentName: SINGLETON_INSTRUMENT,
|
|
41
41
|
config: initialConfig,
|
|
@@ -47,7 +47,7 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
47
47
|
}
|
|
48
48
|
return (
|
|
49
49
|
<LabStoreProvider store={storeRef.current}>
|
|
50
|
-
<
|
|
50
|
+
<TrialIdProvider trialId={id}>{children}</TrialIdProvider>
|
|
51
51
|
</LabStoreProvider>
|
|
52
52
|
);
|
|
53
53
|
}
|
package/src/state/context.tsx
CHANGED
|
@@ -27,25 +27,25 @@ export function useLabStore(): LabStoreState & ReturnType<LabStore['getState']>
|
|
|
27
27
|
return useStore(ctx.store);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Context carrying which
|
|
31
|
-
export const
|
|
30
|
+
/** Context carrying which trial the subtree belongs to. */
|
|
31
|
+
export const TrialIdContext = createContext<string | null>(null);
|
|
32
32
|
|
|
33
|
-
/** Names the
|
|
33
|
+
/** Names the trial its subtree belongs to, so an instrument's hooks can
|
|
34
34
|
* find their own record in the store without being passed an id. */
|
|
35
|
-
export function
|
|
36
|
-
|
|
35
|
+
export function TrialIdProvider({
|
|
36
|
+
trialId,
|
|
37
37
|
children,
|
|
38
38
|
}: {
|
|
39
|
-
|
|
39
|
+
trialId: string;
|
|
40
40
|
children: ReactNode;
|
|
41
41
|
}): ReactElement {
|
|
42
|
-
return <
|
|
42
|
+
return <TrialIdContext.Provider value={trialId}>{children}</TrialIdContext.Provider>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** The id of the
|
|
46
|
-
* `<
|
|
47
|
-
export function
|
|
48
|
-
const id = useContext(
|
|
49
|
-
if (!id) throw new Error('[labkit]
|
|
45
|
+
/** The id of the trial this component is inside. Throws outside a
|
|
46
|
+
* `<TrialIdProvider>`. */
|
|
47
|
+
export function useTrialId(): string {
|
|
48
|
+
const id = useContext(TrialIdContext);
|
|
49
|
+
if (!id) throw new Error('[labkit] useTrialId must be used inside <TrialIdProvider>');
|
|
50
50
|
return id;
|
|
51
51
|
}
|