@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.
Files changed (122) hide show
  1. package/README.md +33 -6
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-JFAYj5Tv.d.ts} +29 -7
  4. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  5. package/dist/_dts/useTrialState-BMNIx3Cy.d.ts +171 -0
  6. package/dist/canvas/index.d.ts +2 -1
  7. package/dist/{chunk-BAPZPDDA.js → chunk-73KA7WBO.js} +425 -394
  8. package/dist/chunk-73KA7WBO.js.map +1 -0
  9. package/dist/{chunk-DWV7SFKR.js → chunk-BOHF3PQO.js} +4397 -3726
  10. package/dist/chunk-BOHF3PQO.js.map +1 -0
  11. package/dist/{chunk-DEWXYFEU.js → chunk-G5TJVQQT.js} +3 -3
  12. package/dist/{chunk-DEWXYFEU.js.map → chunk-G5TJVQQT.js.map} +1 -1
  13. package/dist/chunk-LN6JDUGB.js +106 -0
  14. package/dist/chunk-LN6JDUGB.js.map +1 -0
  15. package/dist/chunk-THBG7FQZ.js +167 -0
  16. package/dist/chunk-THBG7FQZ.js.map +1 -0
  17. package/dist/chunk-VUU5UXHE.js +491 -0
  18. package/dist/chunk-VUU5UXHE.js.map +1 -0
  19. package/dist/dragdrop/index.d.ts +2 -1
  20. package/dist/index.d.ts +211 -151
  21. package/dist/index.js +317 -169
  22. package/dist/index.js.map +1 -1
  23. package/dist/job/index.d.ts +13 -0
  24. package/dist/job/index.js +3 -0
  25. package/dist/job/index.js.map +1 -0
  26. package/dist/layers/index.d.ts +3 -2
  27. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  28. package/dist/passthrough/weasel-canvas.js +1 -1
  29. package/dist/passthrough/weasel-ui.d.ts +29 -6
  30. package/dist/passthrough/weasel-ui.js +2 -2
  31. package/dist/state/index.d.ts +12 -12
  32. package/dist/state/index.js +5 -5
  33. package/dist/state/index.js.map +1 -1
  34. package/dist/styles.css +31 -13
  35. package/dist/surface/index.d.ts +77 -0
  36. package/dist/surface/index.js +3 -0
  37. package/dist/surface/index.js.map +1 -0
  38. package/dist/ui/layers/index.js +3 -3
  39. package/dist/undo/index.d.ts +2 -1
  40. package/package.json +9 -1
  41. package/src/canvas/AGENTS.md +14 -2
  42. package/src/canvas/useOrbit.test.ts +71 -0
  43. package/src/canvas/useOrbit.ts +149 -0
  44. package/src/dragdrop/dragDrop.test.tsx +4 -7
  45. package/src/index.test.ts +70 -0
  46. package/src/index.ts +24 -7
  47. package/src/instrument/SineWave.smoke.test.tsx +2 -1
  48. package/src/instrument/capabilityDetector.ts +1 -1
  49. package/src/instrument/types.ts +30 -6
  50. package/src/job/index.ts +3 -0
  51. package/src/job/types.ts +47 -0
  52. package/src/job/useJob.test.tsx +210 -0
  53. package/src/job/useJob.ts +134 -0
  54. package/src/lab/Lab.less +16 -0
  55. package/src/lab/Lab.stories.tsx +6 -7
  56. package/src/lab/Lab.test.tsx +25 -25
  57. package/src/lab/Lab.tsx +50 -50
  58. package/src/lab/LabContext.ts +12 -12
  59. package/src/lab/LabShell.tsx +1 -1
  60. package/src/lab/{WorkspaceGrid.less → Workspace.less} +2 -2
  61. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +13 -13
  62. package/src/lab/Workspace.surface.test.tsx +49 -0
  63. package/src/lab/{WorkspaceGrid.test.tsx → Workspace.test.tsx} +24 -24
  64. package/src/lab/{WorkspaceGrid.tsx → Workspace.tsx} +32 -19
  65. package/src/lab/index.ts +2 -2
  66. package/src/layers/AGENTS.md +3 -3
  67. package/src/primitives/Toolbar.stories.tsx +2 -2
  68. package/src/state/SingletonExperiment.test.tsx +8 -8
  69. package/src/state/SingletonExperiment.tsx +9 -9
  70. package/src/state/context.tsx +12 -12
  71. package/src/state/document.test.ts +369 -0
  72. package/src/state/document.ts +194 -0
  73. package/src/state/helpers.test.ts +74 -46
  74. package/src/state/helpers.ts +21 -29
  75. package/src/state/index.ts +16 -8
  76. package/src/state/store.test.ts +440 -41
  77. package/src/state/store.ts +165 -102
  78. package/src/state/types.ts +32 -13
  79. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  80. package/src/state/useTrialState.ts +29 -0
  81. package/src/state/view.test.ts +127 -0
  82. package/src/state/view.ts +18 -0
  83. package/src/styles.less +2 -2
  84. package/src/surface/AGENTS.md +64 -0
  85. package/src/surface/SurfaceContext.ts +5 -0
  86. package/src/surface/composeRects.test.ts +50 -0
  87. package/src/surface/composeRects.ts +19 -0
  88. package/src/surface/deviceRect.test.ts +40 -0
  89. package/src/surface/deviceRect.ts +19 -0
  90. package/src/surface/index.ts +7 -0
  91. package/src/surface/rect.ts +16 -0
  92. package/src/surface/useSurfaceTile.test.tsx +67 -0
  93. package/src/surface/useSurfaceTile.ts +32 -0
  94. package/src/surface/useTiledSurface.test.tsx +231 -0
  95. package/src/surface/useTiledSurface.ts +157 -0
  96. package/src/theme/base.less +1 -1
  97. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  98. package/src/trial/DefaultStatusBar.tsx +19 -0
  99. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  100. package/src/trial/Trial.canvas.test.tsx +80 -0
  101. package/src/trial/Trial.job.test.tsx +75 -0
  102. package/src/{workspace/Workspace.less → trial/Trial.less} +13 -1
  103. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +20 -18
  104. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -26
  105. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +77 -46
  106. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +73 -46
  107. package/src/{workspace → trial}/index.ts +13 -13
  108. package/src/{workspace → trial}/slotTypes.ts +18 -17
  109. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +38 -44
  110. package/src/trial/trialOps.ts +99 -0
  111. package/src/ui/format.test.ts +33 -0
  112. package/src/ui/format.ts +11 -0
  113. package/src/ui/properties/CurveField.tsx +6 -6
  114. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  115. package/dist/_dts/useExperimentState-D7EQnnwJ.d.ts +0 -151
  116. package/dist/chunk-BAPZPDDA.js.map +0 -1
  117. package/dist/chunk-DWV7SFKR.js.map +0 -1
  118. package/dist/chunk-KSTEW2AF.js +0 -367
  119. package/dist/chunk-KSTEW2AF.js.map +0 -1
  120. package/src/state/useExperimentState.ts +0 -31
  121. package/src/workspace/DefaultStatusBar.tsx +0 -18
  122. package/src/workspace/workspaceOps.ts +0 -102
@@ -1,21 +1,21 @@
1
1
  import { createContext, useContext } from 'react';
2
- import type { Instrument } from '../instrument/types';
3
- import type { LabMode, SavedSnapshot, WorkspaceRecord } from '../state/types';
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
- * workspaces and the operations over them, saved snapshots, and the color
6
+ * trials and the operations over them, saved snapshots, and the color
7
7
  * mode. */
8
8
  export interface LabContextValue {
9
- instruments: Instrument[];
10
- workspaces: WorkspaceRecord[];
11
- addWorkspace: (instrumentName: string) => void;
12
- cloneWorkspace: (id: string) => void;
13
- closeWorkspace: (id: string) => void;
14
- resetWorkspace: (id: string) => void;
15
- reorderWorkspaces: (ids: readonly string[]) => void;
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: (workspaceId: string, name?: string) => void;
18
- loadSnapshot: (workspaceId: string, snapshotId: string) => void;
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;
@@ -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 workspace runtime is wanted too. */
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-grid {
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-workspace-tile {
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 { WorkspaceGrid } from './WorkspaceGrid';
2
+ import { Workspace } from './Workspace';
3
3
 
4
- const meta: Meta<typeof WorkspaceGrid> = {
5
- title: 'labkit/Lab/WorkspaceGrid',
6
- component: WorkspaceGrid,
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 WorkspaceGrid>;
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
- <WorkspaceGrid>
31
+ <Workspace>
32
32
  <Tile>1</Tile>
33
- </WorkspaceGrid>
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
- <WorkspaceGrid>
41
+ <Workspace>
42
42
  <Tile>1</Tile>
43
43
  <Tile>2</Tile>
44
44
  <Tile>3</Tile>
45
- </WorkspaceGrid>
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
- <WorkspaceGrid>
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
- </WorkspaceGrid>
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
- <WorkspaceGrid ids={['a', 'b', 'c', 'd', 'e']} resizable>
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
- </WorkspaceGrid>
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 { WorkspaceGrid } from './WorkspaceGrid';
3
+ import { Workspace } from './Workspace';
4
4
 
5
5
  const VIEWPORT = { w: 800, h: 600 };
6
6
 
7
- describe('WorkspaceGrid', () => {
7
+ describe('Workspace', () => {
8
8
  test('renders all children', () => {
9
9
  render(
10
- <WorkspaceGrid viewport={VIEWPORT}>
10
+ <Workspace viewport={VIEWPORT}>
11
11
  <div>one</div>
12
12
  <div>two</div>
13
13
  <div>three</div>
14
- </WorkspaceGrid>,
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
- <WorkspaceGrid ids={ids} viewport={VIEWPORT}>
24
+ <Workspace ids={ids} viewport={VIEWPORT}>
25
25
  <div>a</div>
26
26
  <div>b</div>
27
27
  <div>c</div>
28
- </WorkspaceGrid>,
28
+ </Workspace>,
29
29
  );
30
30
  expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
31
31
 
32
32
  rerender(
33
- <WorkspaceGrid ids={['b', 'c']} viewport={VIEWPORT}>
33
+ <Workspace ids={['b', 'c']} viewport={VIEWPORT}>
34
34
  <div>b</div>
35
35
  <div>c</div>
36
- </WorkspaceGrid>,
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-grid class', () => {
42
+ test('uses lk-workspace class', () => {
43
43
  const { container } = render(
44
- <WorkspaceGrid viewport={VIEWPORT}>
44
+ <Workspace viewport={VIEWPORT}>
45
45
  <div />
46
- </WorkspaceGrid>,
46
+ </Workspace>,
47
47
  );
48
- expect((container.firstChild as HTMLElement).className).toContain('lk-workspace-grid');
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
- <WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
53
+ <Workspace ids={['a', 'b']} viewport={VIEWPORT}>
54
54
  <div>a</div>
55
55
  <div>b</div>
56
- </WorkspaceGrid>,
56
+ </Workspace>,
57
57
  );
58
58
  expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
59
59
 
60
60
  rerender(
61
- <WorkspaceGrid ids={['a', 'b']} resizable viewport={VIEWPORT}>
61
+ <Workspace ids={['a', 'b']} resizable viewport={VIEWPORT}>
62
62
  <div>a</div>
63
63
  <div>b</div>
64
- </WorkspaceGrid>,
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
- <WorkspaceGrid
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
- </WorkspaceGrid>,
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
- <WorkspaceGrid ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
90
+ <Workspace ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
91
91
  <div>a</div>
92
92
  <div>b</div>
93
- </WorkspaceGrid>,
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-workspace-tile__grip')).toHaveLength(2);
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
- <WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
107
+ <Workspace ids={['a', 'b']} viewport={VIEWPORT}>
108
108
  <div>a</div>
109
109
  <div>b</div>
110
- </WorkspaceGrid>,
110
+ </Workspace>,
111
111
  );
112
- expect(container.querySelectorAll('.lk-workspace-tile__grip')).toHaveLength(0);
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
- const ZONE_ID = asNodeId('lk-workspaces');
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 = 'workspace';
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 WorkspaceLayout = Record<
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 WorkspaceGridProps {
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?: WorkspaceLayout;
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: WorkspaceLayout) => void;
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): WorkspaceLayout[string] | null {
66
- const p = store.getNode(id)?.membership?.placement as WorkspaceLayout[string] | undefined;
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: WorkspaceLayout[string] = {};
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 workspaces, `ceil(sqrt(n))` columns wide.
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 WorkspaceGrid({
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
- }: WorkspaceGridProps) {
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: WorkspaceLayout = {};
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-workspace-tile">
168
- <DragHandle nodeId={node.id} className="lk-workspace-tile__grip">
169
- <span className="lk-workspace-tile__grip-dots" aria-hidden="true" />
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-workspace-tile__body">{content}</div>
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-grid windease-zone"
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 { WorkspaceGridProps } from './WorkspaceGrid';
8
- export { WorkspaceGrid } from './WorkspaceGrid';
7
+ export type { WorkspaceProps } from './Workspace';
8
+ export { Workspace } from './Workspace';
@@ -31,9 +31,9 @@ The instrument declares layer ids:
31
31
  layers: { ids: ['grid', 'plants'] }
32
32
  ```
33
33
 
34
- `Workspace.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>`.
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 workspace 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.
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". Workspace already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
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 Workspace</Toolbar.Title>
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 workspace</Toolbar.Title>
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 { useExperimentState } from './useExperimentState';
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 useExperimentState', () => {
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(() => useExperimentState<State, Config>(), { wrapper });
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 = useExperimentState<State, Config>();
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
- // createLabStore uses labStorageKey which prefixes with "lk:"
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
- // createLabStore uses labStorageKey which prefixes with "lk:"
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(() => useExperimentState<State, Config>(), { wrapper });
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, WorkspaceIdProvider } from './context';
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 workspace; also doubles as the
11
- * WorkspaceIdContext value. */
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-workspace `<Lab>` substitute for single-screen experiments. Mounts
22
- * a `LabStoreProvider` + `WorkspaceIdProvider` with one synthetic
23
- * workspace, so `useExperimentState` works without going through the
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().workspaces.some((w) => w.id === id)) {
38
- store.getState().addWorkspace({
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
- <WorkspaceIdProvider workspaceId={id}>{children}</WorkspaceIdProvider>
50
+ <TrialIdProvider trialId={id}>{children}</TrialIdProvider>
51
51
  </LabStoreProvider>
52
52
  );
53
53
  }
@@ -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 workspace the subtree belongs to. */
31
- export const WorkspaceIdContext = createContext<string | null>(null);
30
+ /** Context carrying which trial the subtree belongs to. */
31
+ export const TrialIdContext = createContext<string | null>(null);
32
32
 
33
- /** Names the workspace its subtree belongs to, so an instrument's hooks can
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 WorkspaceIdProvider({
36
- workspaceId,
35
+ export function TrialIdProvider({
36
+ trialId,
37
37
  children,
38
38
  }: {
39
- workspaceId: string;
39
+ trialId: string;
40
40
  children: ReactNode;
41
41
  }): ReactElement {
42
- return <WorkspaceIdContext.Provider value={workspaceId}>{children}</WorkspaceIdContext.Provider>;
42
+ return <TrialIdContext.Provider value={trialId}>{children}</TrialIdContext.Provider>;
43
43
  }
44
44
 
45
- /** The id of the workspace this component is inside. Throws outside a
46
- * `<WorkspaceIdProvider>`. */
47
- export function useWorkspaceId(): string {
48
- const id = useContext(WorkspaceIdContext);
49
- if (!id) throw new Error('[labkit] useWorkspaceId must be used inside <WorkspaceIdProvider>');
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
  }