@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
@@ -0,0 +1,114 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { Workspace } from './Workspace';
4
+
5
+ const VIEWPORT = { w: 800, h: 600 };
6
+
7
+ describe('Workspace', () => {
8
+ test('renders all children', () => {
9
+ render(
10
+ <Workspace viewport={VIEWPORT}>
11
+ <div>one</div>
12
+ <div>two</div>
13
+ <div>three</div>
14
+ </Workspace>,
15
+ );
16
+ expect(screen.getByText('one')).toBeInTheDocument();
17
+ expect(screen.getByText('two')).toBeInTheDocument();
18
+ expect(screen.getByText('three')).toBeInTheDocument();
19
+ });
20
+
21
+ test('keeps a child with the tile its id names when an earlier one closes', () => {
22
+ const ids = ['a', 'b', 'c'];
23
+ const { rerender, container } = render(
24
+ <Workspace ids={ids} viewport={VIEWPORT}>
25
+ <div>a</div>
26
+ <div>b</div>
27
+ <div>c</div>
28
+ </Workspace>,
29
+ );
30
+ expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
31
+
32
+ rerender(
33
+ <Workspace ids={['b', 'c']} viewport={VIEWPORT}>
34
+ <div>b</div>
35
+ <div>c</div>
36
+ </Workspace>,
37
+ );
38
+ expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
39
+ expect(container.querySelector('[data-node="a"]')).toBeNull();
40
+ });
41
+
42
+ test('uses lk-workspace class', () => {
43
+ const { container } = render(
44
+ <Workspace viewport={VIEWPORT}>
45
+ <div />
46
+ </Workspace>,
47
+ );
48
+ expect((container.firstChild as HTMLElement).className).toContain('lk-workspace');
49
+ });
50
+
51
+ test('renders resize affordances only when resizable', () => {
52
+ const { container, rerender } = render(
53
+ <Workspace ids={['a', 'b']} viewport={VIEWPORT}>
54
+ <div>a</div>
55
+ <div>b</div>
56
+ </Workspace>,
57
+ );
58
+ expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
59
+
60
+ rerender(
61
+ <Workspace ids={['a', 'b']} resizable viewport={VIEWPORT}>
62
+ <div>a</div>
63
+ <div>b</div>
64
+ </Workspace>,
65
+ );
66
+ expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
67
+ });
68
+
69
+ test('applies a saved extent to a tile as it registers', () => {
70
+ const { container } = render(
71
+ <Workspace
72
+ ids={['a', 'b']}
73
+ resizable
74
+ viewport={VIEWPORT}
75
+ layout={{ a: { span: { cols: 2 } } }}
76
+ >
77
+ <div>a</div>
78
+ <div>b</div>
79
+ </Workspace>,
80
+ );
81
+ const a = container.querySelector('[data-node="a"]') as HTMLElement;
82
+ const b = container.querySelector('[data-node="b"]') as HTMLElement;
83
+ // `a` holds two columns, so it is wider than the single-column `b`.
84
+ expect(Number.parseFloat(a.style.width)).toBeGreaterThan(Number.parseFloat(b.style.width));
85
+ });
86
+
87
+ test('reports the order a drop would produce instead of applying it', () => {
88
+ const onReorder = vi.fn();
89
+ const { container } = render(
90
+ <Workspace ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
91
+ <div>a</div>
92
+ <div>b</div>
93
+ </Workspace>,
94
+ );
95
+ // The grid is controlled: it renders a handle per tile and commits nothing
96
+ // itself. Order still comes from `ids`.
97
+ expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(2);
98
+ expect(onReorder).not.toHaveBeenCalled();
99
+ const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
100
+ el.getAttribute('data-node'),
101
+ );
102
+ expect(nodes).toEqual(['a', 'b']);
103
+ });
104
+
105
+ test('renders no drag handles unless reorderable', () => {
106
+ const { container } = render(
107
+ <Workspace ids={['a', 'b']} viewport={VIEWPORT}>
108
+ <div>a</div>
109
+ <div>b</div>
110
+ </Workspace>,
111
+ );
112
+ expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(0);
113
+ });
114
+ });
@@ -0,0 +1,196 @@
1
+ import {
2
+ Children,
3
+ type ReactNode,
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ } from 'react';
10
+ import { asNodeId, createNode, gridStrategy, type NodeId, Store } from 'windease';
11
+ import {
12
+ type ChromeMap,
13
+ Container,
14
+ DragHandle,
15
+ DragProvider,
16
+ Provider,
17
+ StrategyRegistryProvider,
18
+ } from 'windease/react';
19
+
20
+ const ZONE_ID = asNodeId('lk-workspace');
21
+ const STRATEGIES = { grid: gridStrategy as never };
22
+ const KIND = 'trial';
23
+
24
+ /** A tile's persisted extent, keyed by the id its caller gave it. Grid resizes
25
+ * write `span`; `size` is here because a strategy swap would write that. */
26
+ export type TrialLayout = Record<
27
+ string,
28
+ { size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
29
+ >;
30
+
31
+ export interface WorkspaceProps {
32
+ children: ReactNode;
33
+ /**
34
+ * Stable id per child, positionally matched. Supply these whenever a tile
35
+ * can be closed from the middle: without them a tile is identified by its
36
+ * position, so closing one shifts every id after it and the panes inherit
37
+ * each other's dragged extents. `layout` and `reorderable` both key off
38
+ * these, so neither means much without them.
39
+ */
40
+ ids?: readonly string[];
41
+ /** Draggable seams between tiles. Off by default — an even tiling is the
42
+ * behavior every existing caller has. */
43
+ resizable?: boolean;
44
+ /** Let a tile be dragged to a new position. Off by default. The grid never
45
+ * reorders `children` itself: it reports the order a drop would produce and
46
+ * the caller commits it. */
47
+ reorderable?: boolean;
48
+ /** The full id list a drop would produce, in its new order. */
49
+ onReorder?: (ids: string[]) => void;
50
+ /** Extents from a previous session, applied to tiles as they register. */
51
+ layout?: TrialLayout;
52
+ /** Fires when a tile's extent changes. Persist it and hand it back as
53
+ * `layout` to make a resize survive a reload. */
54
+ onLayoutChange?: (layout: TrialLayout) => void;
55
+ gap?: number;
56
+ padding?: number;
57
+ /**
58
+ * Fixed tiling extent. Omit in an app — the grid measures its own box. Supply
59
+ * it where nothing measures, notably jsdom: at a zero measurement the grid
60
+ * renders no tiles at all.
61
+ */
62
+ viewport?: { w: number; h: number };
63
+ }
64
+
65
+ function extentOf(store: Store, id: NodeId): TrialLayout[string] | null {
66
+ const p = store.getNode(id)?.membership?.placement as TrialLayout[string] | undefined;
67
+ if (!p) return null;
68
+ const out: TrialLayout[string] = {};
69
+ if (p.size) out.size = p.size;
70
+ if (p.span) out.span = p.span;
71
+ return Object.keys(out).length > 0 ? out : null;
72
+ }
73
+
74
+ /**
75
+ * Auto-balanced tiling of trials, `ceil(sqrt(n))` columns wide.
76
+ *
77
+ * Tiles are absolutely positioned at the rects `gridStrategy` computes, not
78
+ * laid out by CSS — `windease/styles.css` (folded into
79
+ * `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
80
+ */
81
+ export function Workspace({
82
+ children,
83
+ ids,
84
+ resizable = false,
85
+ reorderable = false,
86
+ onReorder,
87
+ layout,
88
+ onLayoutChange,
89
+ gap = 12,
90
+ padding = 0,
91
+ viewport,
92
+ }: WorkspaceProps) {
93
+ const items = Children.toArray(children);
94
+ const idKey = ids ? ids.join(',') : `#${items.length}`;
95
+ // 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]);
97
+
98
+ // Held in refs rather than depended on: a fresh object each render would
99
+ // re-run the sync effect, and only a newly registered tile reads `layout`.
100
+ const layoutRef = useRef(layout);
101
+ layoutRef.current = layout;
102
+ const onLayoutChangeRef = useRef(onLayoutChange);
103
+ onLayoutChangeRef.current = onLayoutChange;
104
+
105
+ // One store for the component's lifetime: a tile's dragged extent lives in
106
+ // its node, so rebuilding the store on every add or close would silently
107
+ // reset every pane.
108
+ const storeRef = useRef<Store | null>(null);
109
+ if (storeRef.current === null) {
110
+ const store = new Store();
111
+ store.registerNode(
112
+ createNode({
113
+ kind: 'zone',
114
+ id: ZONE_ID,
115
+ container: { strategyId: 'grid', config: { resizable, gap, padding } },
116
+ }),
117
+ );
118
+ storeRef.current = store;
119
+ }
120
+ const store = storeRef.current;
121
+
122
+ useLayoutEffect(() => {
123
+ store.updateContainerConfig(ZONE_ID, { resizable, gap, padding });
124
+ }, [store, resizable, gap, padding]);
125
+
126
+ useLayoutEffect(() => {
127
+ const present = new Set(store.getContainerView(ZONE_ID)?.childOrder ?? []);
128
+ const wanted = new Set(nodeIds);
129
+ for (const id of present) {
130
+ if (!wanted.has(id)) store.unregisterNode(id);
131
+ }
132
+ for (const id of nodeIds) {
133
+ if (present.has(id)) continue;
134
+ store.registerNode(createNode({ kind: KIND, id, parentId: ZONE_ID, focus: true }));
135
+ store.showNode(id);
136
+ const saved = layoutRef.current?.[id];
137
+ if (saved) store.patchPlacement(id, saved);
138
+ }
139
+ store.setChildOrder(ZONE_ID, [...nodeIds]);
140
+ }, [store, nodeIds]);
141
+
142
+ useEffect(() => {
143
+ if (!onLayoutChange) return;
144
+ return store.events.on('node.placementChanged', () => {
145
+ const next: TrialLayout = {};
146
+ for (const child of store.getChildren(ZONE_ID)) {
147
+ const extent = extentOf(store, child.id);
148
+ if (extent) next[child.id] = extent;
149
+ }
150
+ onLayoutChangeRef.current?.(next);
151
+ });
152
+ }, [store, onLayoutChange]);
153
+
154
+ const commitOrder = useCallback(
155
+ (nextIds: NodeId[]) => onReorder?.(nextIds.map(String)),
156
+ [onReorder],
157
+ );
158
+
159
+ const chrome = useMemo<ChromeMap>(() => {
160
+ const byId = new Map<string, ReactNode>(nodeIds.map((id, i) => [id, items[i]]));
161
+ return {
162
+ [KIND]: ({ node }) => {
163
+ const content = byId.get(node.id) ?? null;
164
+ if (!reorderable) return content;
165
+ // A tile is full of controls, so the whole thing can't be the handle.
166
+ return (
167
+ <div className="lk-trial-tile">
168
+ <DragHandle nodeId={node.id} className="lk-trial-tile__grip">
169
+ <span className="lk-trial-tile__grip-dots" aria-hidden="true" />
170
+ </DragHandle>
171
+ <div className="lk-trial-tile__body">{content}</div>
172
+ </div>
173
+ );
174
+ },
175
+ };
176
+ }, [nodeIds, items, reorderable]);
177
+
178
+ const grid = (
179
+ <Container
180
+ parentId={ZONE_ID}
181
+ chrome={chrome}
182
+ className="lk-workspace windease-zone"
183
+ affordances={resizable}
184
+ viewport={viewport}
185
+ onChildOrderChange={reorderable ? commitOrder : undefined}
186
+ />
187
+ );
188
+
189
+ return (
190
+ <Provider store={store}>
191
+ <StrategyRegistryProvider strategies={STRATEGIES}>
192
+ {reorderable ? <DragProvider>{grid}</DragProvider> : grid}
193
+ </StrategyRegistryProvider>
194
+ </Provider>
195
+ );
196
+ }
package/src/lab/index.ts CHANGED
@@ -1,10 +1,8 @@
1
- export type { GridDims } from './gridDims';
2
- export { gridDims } from './gridDims';
3
1
  export type { LabProps } from './Lab';
4
2
  export { Lab } from './Lab';
5
3
  export type { LabContextValue } from './LabContext';
6
4
  export { LabContext, useLabContext } from './LabContext';
7
5
  export type { LabShellProps } from './LabShell';
8
6
  export { LabShell } from './LabShell';
9
- export type { WorkspaceGridProps } from './WorkspaceGrid';
10
- 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
  }