@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.
Files changed (94) hide show
  1. package/README.md +6 -6
  2. package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
  3. package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
  4. package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
  5. package/dist/canvas/index.d.ts +1 -1
  6. package/dist/chunk-3TYUJR7Z.js +488 -0
  7. package/dist/chunk-3TYUJR7Z.js.map +1 -0
  8. package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
  9. package/dist/chunk-C6GJKPUI.js.map +1 -0
  10. package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
  11. package/dist/chunk-DJLDIRFN.js.map +1 -0
  12. package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
  13. package/dist/chunk-NRKWVTVT.js.map +1 -0
  14. package/dist/dragdrop/index.d.ts +1 -1
  15. package/dist/index.d.ts +197 -148
  16. package/dist/index.js +275 -161
  17. package/dist/index.js.map +1 -1
  18. package/dist/layers/index.d.ts +2 -2
  19. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  20. package/dist/passthrough/weasel-canvas.js +1 -1
  21. package/dist/passthrough/weasel-ui.d.ts +10 -3
  22. package/dist/passthrough/weasel-ui.js +2 -2
  23. package/dist/state/index.d.ts +14 -14
  24. package/dist/state/index.js +5 -5
  25. package/dist/state/index.js.map +1 -1
  26. package/dist/styles.css +179 -14
  27. package/dist/ui/layers/index.js +3 -3
  28. package/dist/undo/index.d.ts +1 -1
  29. package/package.json +2 -1
  30. package/src/canvas/AGENTS.md +6 -2
  31. package/src/dragdrop/dragDrop.test.tsx +4 -7
  32. package/src/index.test.ts +52 -0
  33. package/src/index.ts +11 -7
  34. package/src/instrument/SineWave.smoke.test.tsx +1 -1
  35. package/src/instrument/capabilityDetector.ts +1 -1
  36. package/src/instrument/types.ts +18 -6
  37. package/src/lab/Lab.less +16 -0
  38. package/src/lab/Lab.stories.tsx +5 -5
  39. package/src/lab/Lab.test.tsx +25 -25
  40. package/src/lab/Lab.tsx +56 -44
  41. package/src/lab/LabContext.ts +12 -11
  42. package/src/lab/LabShell.tsx +1 -1
  43. package/src/lab/Workspace.less +40 -0
  44. package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
  45. package/src/lab/Workspace.test.tsx +114 -0
  46. package/src/lab/Workspace.tsx +196 -0
  47. package/src/lab/index.ts +2 -4
  48. package/src/layers/AGENTS.md +3 -3
  49. package/src/primitives/Toolbar.stories.tsx +2 -2
  50. package/src/state/SingletonExperiment.test.tsx +8 -8
  51. package/src/state/SingletonExperiment.tsx +9 -9
  52. package/src/state/context.tsx +12 -12
  53. package/src/state/document.test.ts +369 -0
  54. package/src/state/document.ts +194 -0
  55. package/src/state/helpers.test.ts +74 -46
  56. package/src/state/helpers.ts +23 -31
  57. package/src/state/index.ts +16 -8
  58. package/src/state/store.test.ts +440 -40
  59. package/src/state/store.ts +166 -90
  60. package/src/state/types.ts +30 -10
  61. package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
  62. package/src/state/useTrialState.ts +29 -0
  63. package/src/styles.less +2 -2
  64. package/src/test-setup.ts +19 -0
  65. package/src/theme/base.less +1 -1
  66. package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
  67. package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
  68. package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
  69. package/src/trial/Trial.canvas.test.tsx +80 -0
  70. package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
  71. package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
  72. package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
  73. package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
  74. package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
  75. package/src/{workspace → trial}/index.ts +13 -12
  76. package/src/{workspace → trial}/slotTypes.ts +16 -16
  77. package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
  78. package/src/trial/trialOps.ts +99 -0
  79. package/src/ui/format.test.ts +33 -0
  80. package/src/ui/format.ts +11 -0
  81. package/src/ui/properties/CurveField.tsx +6 -6
  82. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
  83. package/dist/chunk-3WPOGKUP.js.map +0 -1
  84. package/dist/chunk-73PXCRCR.js.map +0 -1
  85. package/dist/chunk-N5KTQKQA.js.map +0 -1
  86. package/dist/chunk-T7OKNJTY.js +0 -351
  87. package/dist/chunk-T7OKNJTY.js.map +0 -1
  88. package/src/lab/WorkspaceGrid.less +0 -8
  89. package/src/lab/WorkspaceGrid.test.tsx +0 -40
  90. package/src/lab/WorkspaceGrid.tsx +0 -22
  91. package/src/lab/gridDims.test.ts +0 -35
  92. package/src/lab/gridDims.ts +0 -13
  93. package/src/state/useExperimentState.ts +0 -31
  94. 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
- useWorkspaceId,
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
- ExperimentStateHandle,
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 { useExperimentState } from './state/useExperimentState';
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
- workspace: { id: 'w', zoom: 1, setZoom: vi.fn() },
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 workspace
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;
@@ -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 workspace it is mounted in, and a way to emit named events. */
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
- workspace: {
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 workspace should offer
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 workspace's camera. */
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 workspace provide the corresponding chrome.
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
  }
@@ -31,21 +31,21 @@ export const Default: Story = {
31
31
  },
32
32
  };
33
33
 
34
- function AddSecondWorkspace() {
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.workspaces.length < 2) ctx.addWorkspace('Stub');
38
+ if (ctx.trials.length < 2) ctx.addTrial('Stub');
39
39
  }, []);
40
40
  return null;
41
41
  }
42
42
 
43
- export const TwoWorkspaces: Story = {
43
+ export const TwoTrials: Story = {
44
44
  args: {
45
45
  instruments: [StubInstrument],
46
46
  defaultInstrument: 'Stub',
47
- title: 'Two Workspaces',
47
+ title: 'Two Trials',
48
48
  storage: null,
49
- children: <AddSecondWorkspace />,
49
+ children: <AddSecondTrial />,
50
50
  },
51
51
  };
@@ -41,50 +41,50 @@ function mountLab(props: Partial<Parameters<typeof Lab>[0]> = {}) {
41
41
  }
42
42
 
43
43
  describe('<Lab>', () => {
44
- it('renders one workspace by default', () => {
44
+ it('renders one trial by default', () => {
45
45
  mountLab();
46
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
46
+ expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(1);
47
47
  });
48
48
 
49
- it('addWorkspace adds a second workspace', () => {
49
+ it('addTrial adds a second trial', () => {
50
50
  mountLab();
51
- act(() => labRef?.addWorkspace('Stub'));
52
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(2);
51
+ act(() => labRef?.addTrial('Stub'));
52
+ expect(screen.getAllByRole('region', { name: /trial/i })).toHaveLength(2);
53
53
  });
54
54
 
55
- it('closeWorkspace removes one when more than one exists', () => {
55
+ it('closeTrial removes one when more than one exists', () => {
56
56
  mountLab();
57
- act(() => labRef?.addWorkspace('Stub'));
58
- const first = labRef?.workspaces[0];
59
- act(() => labRef?.closeWorkspace(first?.id ?? ''));
60
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
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('closeWorkspace is a no-op on the last workspace', () => {
63
+ it('closeTrial is a no-op on the last trial', () => {
64
64
  mountLab();
65
- const only = labRef?.workspaces[0];
66
- act(() => labRef?.closeWorkspace(only?.id ?? ''));
67
- expect(screen.getAllByRole('region', { name: /workspace/i })).toHaveLength(1);
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('cloneWorkspace inserts immediately after source', () => {
70
+ it('cloneTrial inserts immediately after source', () => {
71
71
  mountLab();
72
- act(() => labRef?.addWorkspace('StubB'));
73
- const ws0 = labRef?.workspaces[0];
74
- const ws1 = labRef?.workspaces[1];
75
- act(() => labRef?.cloneWorkspace(ws0?.id ?? ''));
76
- const ids = labRef?.workspaces.map((w) => w.id) ?? [];
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('resetWorkspace restores defaults', () => {
82
+ it('resetTrial restores defaults', () => {
83
83
  mountLab();
84
- const ws = labRef?.workspaces[0];
85
- if (!ws) throw new Error('no workspace');
86
- act(() => labRef?.resetWorkspace(ws.id));
87
- const reset = labRef?.workspaces[0];
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 { Instrument } from '../instrument/types';
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, WorkspaceRecord } from '../state/types';
8
+ import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
9
9
  import { interstellarTheme } from '../theme/interstellar';
10
- import { Workspace } from '../workspace/Workspace';
10
+ import { Trial } from '../trial/Trial';
11
11
  import {
12
- addWorkspace as addWorkspaceOp,
13
- cloneWorkspace as cloneWorkspaceOp,
14
- closeWorkspace as closeWorkspaceOp,
15
- resetWorkspace as resetWorkspaceOp,
16
- } from '../workspace/workspaceOps';
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 { WorkspaceGrid } from './WorkspaceGrid';
21
+ import { type TrialLayout, Workspace } from './Workspace';
21
22
 
22
23
  /** Props for `<Lab>`. */
23
24
  export interface LabProps {
24
- instruments: Instrument[];
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: Instrument[],
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().workspaces.length === 0) {
49
- const seeded = addWorkspaceOp([], instruments, defaultInstrument);
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().addWorkspace(rest);
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 workspace
81
- * per record in a grid. Each workspace runs one of `instruments`. */
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 workspaces = useStore(store, (s) => s.workspaces);
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 replaceWorkspaces = (next: WorkspaceRecord[]): void => {
121
- const currentById = new Map(store.getState().workspaces.map((w) => [w.id, w]));
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({ workspaces: merged });
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
- workspaces,
131
- addWorkspace: (instrumentName) => {
132
- const next = addWorkspaceOp(store.getState().workspaces, instruments, instrumentName);
133
- replaceWorkspaces(next);
132
+ trials,
133
+ addTrial: (instrumentName) => {
134
+ const next = addTrialOp(store.getState().trials, instruments, instrumentName);
135
+ replaceTrials(next);
134
136
  },
135
- cloneWorkspace: (id) => {
136
- const next = cloneWorkspaceOp(store.getState().workspaces, id);
137
- replaceWorkspaces(next);
137
+ cloneTrial: (id) => {
138
+ const next = cloneTrialOp(store.getState().trials, id);
139
+ replaceTrials(next);
138
140
  },
139
- closeWorkspace: (id) => {
140
- const next = closeWorkspaceOp(store.getState().workspaces, id);
141
- replaceWorkspaces(next);
141
+ closeTrial: (id) => {
142
+ const next = closeTrialOp(store.getState().trials, id);
143
+ replaceTrials(next);
142
144
  },
143
- resetWorkspace: (id) => {
144
- const next = resetWorkspaceOp(store.getState().workspaces, id, instruments);
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
- workspaces: s.workspaces.map((w) =>
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().updateWorkspaceView(id, record.view);
159
+ store.getState().updateTrialView(id, record.view);
155
160
  },
156
161
  savedSnapshots,
157
- saveSnapshot: (workspaceId, name) => {
158
- store.getState().saveSnapshot(workspaceId, name ?? `Save ${new Date().toLocaleString()}`);
162
+ saveSnapshot: (trialId, name) => {
163
+ store.getState().saveSnapshot(trialId, name ?? `Save ${new Date().toLocaleString()}`);
159
164
  },
160
- loadSnapshot: (workspaceId, snapshotId) => {
161
- store.getState().loadSnapshot(snapshotId, workspaceId);
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, workspaces, savedSnapshots, modeValue, store]);
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
- <WorkspaceGrid>
191
- {workspaces.map((w) => (
192
- <Workspace key={w.id} id={w.id} />
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
- </WorkspaceGrid>
206
+ </Workspace>
195
207
  </LabShell>
196
208
  </ThemeProvider>
197
209
  </LabContext.Provider>
@@ -1,20 +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;
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: (workspaceId: string, name?: string) => void;
17
- loadSnapshot: (workspaceId: string, snapshotId: string) => void;
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;
@@ -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();
@@ -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 { 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,28 @@ 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
+ </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
  };