@weasel-js/labkit 1.4.3 → 1.5.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 (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +123 -348
  53. package/dist/index.js +794 -837
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +169 -117
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +163 -62
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -4,12 +4,13 @@
4
4
  * follow. The overlay's own geometry is covered in
5
5
  * `annotations/Annotations.overlay.test.tsx`.
6
6
  */
7
- import { act, fireEvent, render, screen } from '@testing-library/react';
7
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
8
8
  import { beforeAll, describe, expect, it, vi } from 'vitest';
9
9
  import { useAnnotations, useAnnotationsOptional } from '../annotations/AnnotationsContext';
10
10
  import type { AnnotationsApi } from '../annotations/types';
11
11
  import { defineInstrument } from '../instrument/defineInstrument';
12
12
  import { Lab } from '../lab/Lab';
13
+ import { LabContext, type LabContextValue } from '../lab/LabContext';
13
14
 
14
15
  beforeAll(() => {
15
16
  HTMLCanvasElement.prototype.getContext = vi.fn(
@@ -19,9 +20,9 @@ beforeAll(() => {
19
20
 
20
21
  let api: AnnotationsApi | null = null;
21
22
 
22
- function Pane() {
23
+ function Pane({ tool }: { tool: string | null }) {
23
24
  api = useAnnotations();
24
- return <div data-testid="pane" data-marks={api.query().length} />;
25
+ return <div data-testid="pane" data-marks={api.query().length} data-tool={tool ?? ''} />;
25
26
  }
26
27
 
27
28
  function marks(): AnnotationsApi {
@@ -33,7 +34,7 @@ const annotating = defineInstrument<Record<string, never>, Record<string, never>
33
34
  name: 'Annotating',
34
35
  defaultConfig: () => ({}),
35
36
  initialState: () => ({}),
36
- render: () => <Pane />,
37
+ render: (ctx) => <Pane tool={ctx.trial.activeToolId} />,
37
38
  annotations: {
38
39
  targets: () => [{ id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } }],
39
40
  },
@@ -72,6 +73,48 @@ describe('an instrument that declares annotations', () => {
72
73
  });
73
74
  });
74
75
 
76
+ let labRef: LabContextValue | null = null;
77
+
78
+ function CaptureLab() {
79
+ return (
80
+ <LabContext.Consumer>
81
+ {(value) => {
82
+ if (value) labRef = value;
83
+ return null;
84
+ }}
85
+ </LabContext.Consumer>
86
+ );
87
+ }
88
+
89
+ describe('the annotation tool', () => {
90
+ it("is offered in the lab's rail, not in a trial's", () => {
91
+ render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
92
+ expect(screen.getAllByRole('button', { name: 'Rectangle' })).toHaveLength(1);
93
+ const trial = screen.getByRole('region', { name: /trial/i });
94
+ expect(within(trial).queryByRole('button', { name: 'Rectangle' })).toBeNull();
95
+ });
96
+
97
+ it('is one tool across every trial', () => {
98
+ // A tool is what the hand is holding, not a property of a picture: picking
99
+ // Rectangle once has to arm it wherever the next mark is drawn.
100
+ labRef = null;
101
+ render(
102
+ <Lab instruments={[annotating]} defaultInstrument="Annotating">
103
+ <CaptureLab />
104
+ </Lab>,
105
+ );
106
+ act(() => labRef?.addTrial('Annotating'));
107
+ const panes = screen.getAllByTestId('pane');
108
+ expect(panes).toHaveLength(2);
109
+ for (const p of panes) expect(p.dataset.tool).toBe('select');
110
+
111
+ act(() => {
112
+ fireEvent.click(screen.getByRole('button', { name: 'Rectangle' }));
113
+ });
114
+ for (const p of screen.getAllByTestId('pane')) expect(p.dataset.tool).toBe('rect');
115
+ });
116
+ });
117
+
75
118
  describe('the trial undo chrome over marks', () => {
76
119
  it('gets undo and redo without the instrument declaring `undo`', () => {
77
120
  // Weasel history is the authority for marks, so the capability that
@@ -36,7 +36,7 @@ function makeInstrument(name: string, n: number, delay: number) {
36
36
  describe('a trial whose instrument declares a job', () => {
37
37
  it('shows progress in the trial chrome', async () => {
38
38
  const fast = makeInstrument('fast', 3, 1);
39
- render(<Lab instruments={[fast]} defaultInstrument="fast" storage={null} />);
39
+ render(<Lab instruments={[fast]} defaultInstrument="fast" />);
40
40
  const bar = await waitFor(() => screen.getByRole('progressbar', { name: /job progress/i }));
41
41
  await waitFor(() => {
42
42
  // A job that reported a total drives a determinate bar, so the count and
@@ -49,7 +49,7 @@ describe('a trial whose instrument declares a job', () => {
49
49
 
50
50
  it('offers a cancel control while running, and drops it once cancelled', async () => {
51
51
  const slow = makeInstrument('slow', 40, 20);
52
- render(<Lab instruments={[slow]} defaultInstrument="slow" storage={null} />);
52
+ render(<Lab instruments={[slow]} defaultInstrument="slow" />);
53
53
  const cancel = await waitFor(() => screen.getByRole('button', { name: /cancel/i }));
54
54
  await userEvent.click(cancel);
55
55
  await waitFor(() => {
@@ -64,7 +64,7 @@ describe('a trial whose instrument declares a job', () => {
64
64
  initialState: () => ({ items: [] }),
65
65
  render: () => null,
66
66
  });
67
- render(<Lab instruments={[plain]} defaultInstrument="plain" storage={null} />);
67
+ render(<Lab instruments={[plain]} defaultInstrument="plain" />);
68
68
  await waitFor(() => {
69
69
  expect(document.querySelector('.lk-trial')).toBeInTheDocument();
70
70
  });
@@ -9,7 +9,7 @@
9
9
  // from its own workspace by nothing but a hairline. Raised reads as a panel,
10
10
  // and in dark it is translucent, so the backdrop still shows through.
11
11
  background: var(--wzl-surface-raised);
12
- border: var(--wzl-border-w) solid var(--wzl-border-raised);
12
+ border: var(--wzl-border-w) solid var(--wzl-border-strong);
13
13
  border-radius: var(--wzl-radius-md);
14
14
  box-shadow: 0 1px 3px var(--wzl-shadow);
15
15
  overflow: hidden;
@@ -96,21 +96,6 @@
96
96
  flex-shrink: 0;
97
97
  }
98
98
 
99
- // The strip's own seam. windease positions and sizes it; this is only what
100
- // it looks like.
101
- &__panes [data-affordance-kind='resize-x'] {
102
- background: var(--wzl-border);
103
- }
104
-
105
- &__panes .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
106
- &__panes .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
107
- background: var(--wzl-accent);
108
- }
109
-
110
- &__panes .windease-affordance-hit:focus-visible {
111
- outline: none;
112
- }
113
-
114
99
  &--unknown {
115
100
  display: flex;
116
101
  align-items: center;
@@ -126,16 +111,6 @@
126
111
  padding: 0 var(--wzl-space-sm);
127
112
  }
128
113
 
129
- .lk-toolbar__load-select {
130
- font-size: var(--wzl-font-size-sm);
131
- }
132
-
133
- // Same `width: 100%` default as the header's Select — pinned so it cannot
134
- // absorb the toolbar's slack. Placeholder-only, so it need only fit "Load…".
135
- .lk-root .lk-toolbar__load-select {
136
- width: 88px;
137
- }
138
-
139
114
  .lk-sidebar__placeholder {
140
115
  padding: var(--wzl-space-sm);
141
116
  color: var(--wzl-fg-muted);
@@ -1,7 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import type { Instrument } from '../instrument/types';
3
3
  import { LabContext, type LabContextValue } from '../lab/LabContext';
4
- import { noneAdapter } from '../state/adapters';
5
4
  import { LabStoreContext } from '../state/context';
6
5
  import { createLabStore } from '../state/store';
7
6
  import type { TrialRecord } from '../state/types';
@@ -27,7 +26,7 @@ const record: TrialRecord = {
27
26
  };
28
27
 
29
28
  function Harness() {
30
- const store = createLabStore({ storageKey: 'sb', storage: noneAdapter });
29
+ const store = createLabStore();
31
30
  const lab: LabContextValue = {
32
31
  instruments: [stub],
33
32
  trials: [record],
@@ -35,10 +35,7 @@ type ChromeProps = Parameters<typeof TrialChrome>[0];
35
35
  * and section folds live on the record, so a test that outlives one mount
36
36
  * keeps its own store and hands it to both. */
37
37
  function makeChromeStore() {
38
- const store = createLabStore({
39
- storageKey: 'test',
40
- storage: { read: () => null, write: () => {} },
41
- });
38
+ const store = createLabStore();
42
39
  const { undoStack: _undo, ...seed } = stubRecord;
43
40
  store.getState().addTrial(seed);
44
41
  return store;
@@ -205,6 +202,33 @@ describe('<TrialChrome>', () => {
205
202
  expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
206
203
  });
207
204
 
205
+ it("reads the instrument's title over its name, and returns to it on setTitle(null)", () => {
206
+ const titled: Instrument = { ...stubInstrument, title: 'Stub / Default' };
207
+ render(
208
+ <ChromeHarness
209
+ instrument={titled}
210
+ chrome={[
211
+ {
212
+ id: 'rename',
213
+ region: 'titlebar',
214
+ item: { icon: Glyph, label: 'Rename', onActivate: (ctx) => ctx.setTitle('Sprocket 7') },
215
+ },
216
+ {
217
+ id: 'untitle',
218
+ region: 'titlebar',
219
+ item: { icon: Glyph, label: 'Untitle', onActivate: (ctx) => ctx.setTitle(null) },
220
+ },
221
+ ]}
222
+ />,
223
+ );
224
+ expect(screen.getByRole('region', { name: 'Trial Stub / Default' })).toBeInTheDocument();
225
+ expect(screen.getByText('Stub / Default')).toBeInTheDocument();
226
+ fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
227
+ expect(screen.getByRole('region', { name: 'Trial Sprocket 7' })).toBeInTheDocument();
228
+ fireEvent.click(screen.getByRole('button', { name: 'Untitle' }));
229
+ expect(screen.getByRole('region', { name: 'Trial Stub / Default' })).toBeInTheDocument();
230
+ });
231
+
208
232
  it('Cmd+S triggers saveSnapshot', () => {
209
233
  const saveSnapshot = vi.fn();
210
234
  render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
@@ -4,10 +4,11 @@
4
4
  * `SingletonExperiment` — so `useTrialState()` threw everywhere else, which
5
5
  * made the documented hook pattern unreachable from an instrument's `render`.
6
6
  */
7
- import { render, screen } from '@testing-library/react';
7
+ import { act, render, screen } from '@testing-library/react';
8
8
  import { beforeAll, describe, expect, it, vi } from 'vitest';
9
9
  import { defineInstrument } from '../instrument/defineInstrument';
10
10
  import { Lab } from '../lab/Lab';
11
+ import { LabContext, type LabContextValue } from '../lab/LabContext';
11
12
  import { useTrialState } from '../state/useTrialState';
12
13
 
13
14
  beforeAll(() => {
@@ -34,3 +35,24 @@ describe('<Trial> trial-scoped hooks', () => {
34
35
  expect(screen.getByTestId('probe')).toHaveTextContent('hits:7');
35
36
  });
36
37
  });
38
+
39
+ describe('<Trial> root element', () => {
40
+ it('carries its trial id, so a lab can find a trial from outside', () => {
41
+ let labRef: LabContextValue | null = null;
42
+ const { container } = render(
43
+ <Lab instruments={[reader]} defaultInstrument="Reader">
44
+ <LabContext.Consumer>
45
+ {(value) => {
46
+ if (value) labRef = value;
47
+ return null;
48
+ }}
49
+ </LabContext.Consumer>
50
+ </Lab>,
51
+ );
52
+ act(() => (labRef as LabContextValue | null)?.addTrial('Reader'));
53
+ const ids = (labRef as LabContextValue | null)?.trials.map((t) => t.id) ?? [];
54
+ expect(ids).toHaveLength(2);
55
+ const roots = container.querySelectorAll<HTMLElement>('.lk-trial[data-trial-id]');
56
+ expect([...roots].map((el) => el.dataset.trialId)).toEqual(ids);
57
+ });
58
+ });
@@ -2,15 +2,24 @@ import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from
2
2
  import { useStore } from 'zustand/react';
3
3
  import { AnnotationsContext } from '../annotations/AnnotationsContext';
4
4
  import { AnnotationTargets } from '../annotations/AnnotationTargets';
5
+ import { AnnotationPreloadContext } from '../annotations/preload';
5
6
  import { annotationsFromJSON } from '../annotations/store';
6
- import type { AnnotationTargetInfo } from '../annotations/types';
7
+ import type { AnnotationStorage, AnnotationTargetInfo } from '../annotations/types';
8
+ import { CameraWheelContext, type CameraWheelSlot } from '../canvas/CameraWheelContext';
7
9
  import { CanvasStack } from '../canvas/CanvasStack';
10
+ import { fitStage, Stage } from '../canvas/Stage';
8
11
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
9
12
  import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
10
13
  import type { TrialContribution } from '../chrome/types';
11
14
  import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
12
15
  import { Palette } from '../dragdrop/Palette';
13
- import type { Instrument, LayerDescriptor, PaletteItem, RenderContext } from '../instrument/types';
16
+ import type {
17
+ Instrument,
18
+ InstrumentList,
19
+ LayerDescriptor,
20
+ PaletteItem,
21
+ RenderContext,
22
+ } from '../instrument/types';
14
23
  import { useJob } from '../job/useJob';
15
24
  import { useLabContext } from '../lab/LabContext';
16
25
  import { LayerList } from '../layers/LayerList';
@@ -19,7 +28,8 @@ import { resolveLoupe } from '../loupe/types';
19
28
  import { LabStoreContext, TrialIdProvider } from '../state/context';
20
29
  import type { LabStore } from '../state/store';
21
30
  import type { TrialRecord } from '../state/types';
22
- import { as2DView, DEFAULT_VIEW } from '../state/view';
31
+ import { as2DView, DEFAULT_VIEW, withZoom } from '../state/view';
32
+ import { resolveLabTool } from '../tools/labTool';
23
33
  import { createEventBus, type EventBus } from '../undo/eventBus';
24
34
  import { pushSnapshot, redo as undoRedo, undo as undoUndo } from '../undo/undoStack';
25
35
  import type { LoupeBindings, UndoBindings } from './TrialChrome';
@@ -41,20 +51,25 @@ export function Trial({ id, chrome, suppress }: TrialProps) {
41
51
  const lab = useLabContext();
42
52
  const storeCtx = useContext(LabStoreContext);
43
53
  if (!storeCtx) throw new Error('[labkit] <Trial> requires <LabStoreProvider>');
54
+ const instruments = useStore(storeCtx.store, (s) => s.instruments) ?? lab.instruments;
44
55
  const record = useStore(storeCtx.store, (s) => s.trials.find((w) => w.id === id));
56
+ const instrument = record ? instruments.find((i) => i.name === record.instrumentName) : undefined;
57
+ const kept = useKeptMarks(instrument?.annotations?.storage, id);
45
58
  if (!record) {
46
59
  return <div className="lk-trial lk-trial--unknown">Trial not found: {id}</div>;
47
60
  }
48
- const instrument = lab.instruments.find((i) => i.name === record.instrumentName);
49
61
  if (!instrument) {
50
62
  return (
51
63
  <div className="lk-trial lk-trial--unknown">Unknown instrument: {record.instrumentName}</div>
52
64
  );
53
65
  }
66
+ if (!kept.ready) return <div className="lk-trial lk-trial--loading" />;
54
67
  return (
55
68
  <TrialRuntime
69
+ keptMarks={kept.value}
56
70
  record={record}
57
71
  instrument={instrument}
72
+ instruments={instruments}
58
73
  store={storeCtx.store}
59
74
  isLast={lab.trials.length <= 1}
60
75
  chrome={chrome}
@@ -63,16 +78,60 @@ export function Trial({ id, chrome, suppress }: TrialProps) {
63
78
  );
64
79
  }
65
80
 
81
+ /** The marks an instrument keeps in its own storage: preloaded by the lab for
82
+ * the trials it opened with, loaded here for any added since. */
83
+ function useKeptMarks(
84
+ storage: AnnotationStorage | undefined,
85
+ trialId: string,
86
+ ): { ready: boolean; value: unknown } {
87
+ const preload = useContext(AnnotationPreloadContext);
88
+ const [loaded, setLoaded] = useState<{ value: unknown } | null>(() =>
89
+ storage && preload?.has(trialId) ? { value: preload.get(trialId) } : null,
90
+ );
91
+ useEffect(() => {
92
+ if (!storage || loaded) return;
93
+ let live = true;
94
+ storage.load().then(
95
+ (value) => {
96
+ if (live) setLoaded({ value });
97
+ },
98
+ (error) => {
99
+ console.warn(`[labkit] could not load the marks of trial "${trialId}"`, error);
100
+ if (live) setLoaded({ value: null });
101
+ },
102
+ );
103
+ return () => {
104
+ live = false;
105
+ };
106
+ }, [storage, loaded, trialId]);
107
+ if (!storage) return { ready: true, value: undefined };
108
+ return loaded ? { ready: true, value: loaded.value } : { ready: false, value: undefined };
109
+ }
110
+
66
111
  interface TrialRuntimeProps {
112
+ /** The marks an instrument's own `AnnotationStorage` loaded. */
113
+ keptMarks: unknown;
67
114
  record: TrialRecord;
68
115
  instrument: Instrument;
116
+ /** The lab's instruments as the store holds them now. */
117
+ instruments: InstrumentList;
69
118
  store: LabStore;
70
119
  isLast: boolean;
71
120
  chrome?: readonly TrialContribution[];
72
121
  suppress?: readonly string[];
73
122
  }
74
123
 
75
- function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: TrialRuntimeProps) {
124
+ function TrialRuntime({
125
+ keptMarks,
126
+ record,
127
+ instrument,
128
+ instruments,
129
+ store,
130
+ isLast,
131
+ chrome,
132
+ suppress,
133
+ }: TrialRuntimeProps) {
134
+ const wheelSlot = useRef<CameraWheelSlot['current']>(null);
76
135
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
77
136
  const loupeHostRef = useRef<HTMLDivElement | null>(null);
78
137
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
@@ -117,8 +176,11 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
117
176
  // A function `initialView` needs the canvas size, so `trialOps` leaves the
118
177
  // view null and the first measurement resolves it. Reset nulls it again,
119
178
  // which re-frames — so the guard is the null itself, not a "have I run" flag.
179
+ const stage = instrument.canvas ? undefined : instrument.stage;
120
180
  const placeView = (size: ViewportSize): void => {
121
- const declared = instrument.canvas?.initialView;
181
+ const declared =
182
+ instrument.canvas?.initialView ??
183
+ (stage ? (stage.initialView ?? ((vp: ViewportSize) => fitStage(stage.size, vp))) : undefined);
122
184
  if (typeof declared !== 'function') return;
123
185
  if (record.view != null) return;
124
186
  setView(declared(size));
@@ -134,21 +196,23 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
134
196
  ? annotationsCap.targets(record.state, record.config, { id: record.id, view: record.view })
135
197
  : [];
136
198
 
137
- // One store for the trial's lifetime. Marks do not survive a reload yet —
138
- // the storage slot is 3d.
199
+ // One store for the trial's lifetime.
139
200
  const annotationsRef = useRef<ReturnType<typeof annotationsFromJSON> | null>(null);
140
201
  // Read through refs for the same reason `targets` is: the store is built
141
202
  // once, and an export must draw against the config the trial holds now.
142
203
  const configRef = useRef<unknown>(record.config);
143
204
  configRef.current = record.config;
205
+ // The instrument can be swapped under a live trial, so the capability is read at use too.
206
+ const capRef = useRef(annotationsCap);
207
+ capRef.current = annotationsCap;
144
208
  if (annotationsRef.current === null) {
145
209
  // Seeded from wherever the marks were kept: the instrument's own store if
146
210
  // it declared one, else this trial's slot.
147
- const kept = annotationsCap?.storage ? annotationsCap.storage.load() : record.annotations;
211
+ const kept = annotationsCap?.storage ? keptMarks : record.annotations;
148
212
  annotationsRef.current = annotationsFromJSON(kept, () => targetsRef.current(), {
149
- meaning: annotationsCap?.meaning,
213
+ meaning: () => capRef.current?.meaning,
150
214
  config: () => configRef.current,
151
- onCapture: (result) => annotationsCap?.onCapture?.(result),
215
+ onCapture: (result) => capRef.current?.onCapture?.(result),
152
216
  });
153
217
  }
154
218
  const annotations = annotationsRef.current;
@@ -203,15 +267,14 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
203
267
 
204
268
  // A trial gets its own slot when its instrument declares tools; otherwise it
205
269
  // reads the lab's. Which slot a change writes follows from the same thing.
206
- // Annotation tools are a trial's own for the same reason: two trials
207
- // annotating different pictures must not share one active tool.
208
- const declaresTools = instrument.tools != null || instrument.annotations != null;
270
+ // Annotation tools always read the lab's: a tool is what the hand holds, so
271
+ // picking one arms it in every trial, the way artboards share a tool. They
272
+ // were once per trial, back when one trial compared several pictures.
273
+ const declaresTools = instrument.tools != null;
274
+ const labTool = resolveLabTool(labToolId, instruments);
209
275
  const resolvedToolId = declaresTools
210
- ? (record.activeToolId ??
211
- instrument.tools?.initial ??
212
- instrument.tools?.tools[0]?.id ??
213
- (instrument.annotations ? 'select' : null))
214
- : labToolId;
276
+ ? (record.activeToolId ?? instrument.tools?.initial ?? instrument.tools?.tools[0]?.id ?? null)
277
+ : labTool;
215
278
  const setActiveTool = (id: string): void => {
216
279
  if (declaresTools) setTrialTool(record.id, id);
217
280
  else setLabTool(id);
@@ -254,7 +317,7 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
254
317
  zoom: view2d?.zoom ?? 1,
255
318
  setZoom: (z) => {
256
319
  if (!view2d) return;
257
- updateTrialView(record.id, { ...view2d, zoom: z });
320
+ updateTrialView(record.id, withZoom(view2d, z));
258
321
  },
259
322
  activeToolId: resolvedToolId,
260
323
  visibleLayers,
@@ -412,6 +475,21 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
412
475
  <DragOverlay drag={dragDropResult.drag} />
413
476
  </div>
414
477
  );
478
+ } else if (stage) {
479
+ body = (
480
+ <Stage
481
+ size={stage.size}
482
+ view={view2d ?? DEFAULT_VIEW}
483
+ onViewChange={setView}
484
+ onResize={placeView}
485
+ minZoom={stage.minZoom}
486
+ maxZoom={stage.maxZoom}
487
+ hostRef={loupeHostRef}
488
+ overlay={lens}
489
+ >
490
+ {instrument.render(renderCtx)}
491
+ </Stage>
492
+ );
415
493
  } else if (lens) {
416
494
  body = (
417
495
  <div ref={loupeHostRef} className="lk-trial__loupe-host">
@@ -478,30 +556,32 @@ function TrialRuntime({ record, instrument, store, isLast, chrome, suppress }: T
478
556
  config={record.config}
479
557
  trial={{ id: record.id, view: record.view }}
480
558
  annotations={annotations}
481
- activeToolId={resolvedToolId}
559
+ activeToolId={labTool}
482
560
  />
483
561
  ) : null;
484
562
 
485
563
  return (
486
564
  <TrialIdProvider trialId={record.id}>
487
565
  <AnnotationsContext.Provider value={annotationsCap ? annotations : null}>
488
- <TrialChrome
489
- job={jobCap ? job : undefined}
490
- loupe={loupeBindings}
491
- trialId={record.id}
492
- record={record}
493
- instrument={instrument}
494
- isLastTrial={isLast}
495
- undoBindings={undoBindings}
496
- trialChrome={extraChrome}
497
- chrome={chrome}
498
- suppress={suppress}
499
- activeToolId={resolvedToolId}
500
- setActiveTool={setActiveTool}
501
- >
502
- {body}
503
- {annotationOverlays}
504
- </TrialChrome>
566
+ <CameraWheelContext.Provider value={wheelSlot}>
567
+ <TrialChrome
568
+ job={jobCap ? job : undefined}
569
+ loupe={loupeBindings}
570
+ trialId={record.id}
571
+ record={record}
572
+ instrument={instrument}
573
+ isLastTrial={isLast}
574
+ undoBindings={undoBindings}
575
+ trialChrome={extraChrome}
576
+ chrome={chrome}
577
+ suppress={suppress}
578
+ activeToolId={resolvedToolId}
579
+ setActiveTool={setActiveTool}
580
+ >
581
+ {body}
582
+ {annotationOverlays}
583
+ </TrialChrome>
584
+ </CameraWheelContext.Provider>
505
585
  </AnnotationsContext.Provider>
506
586
  </TrialIdProvider>
507
587
  );