@weasel-js/labkit 1.4.4 → 1.5.1

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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -0,0 +1,125 @@
1
+ import { act, render, screen, waitFor } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createMemoryAdapter } from './adapters';
4
+ import { labPrefix } from './labRecords';
5
+ import { Persistence } from './Persistence';
6
+ import { SingletonExperimentProvider } from './SingletonExperiment';
7
+ import { type PersistedStateOptions, usePersistedState } from './usePersistedState';
8
+
9
+ function Tab({
10
+ name = 'tab',
11
+ label = 'tab',
12
+ options,
13
+ }: {
14
+ name?: string | null;
15
+ label?: string;
16
+ options?: PersistedStateOptions;
17
+ }) {
18
+ const [value, setValue] = usePersistedState(name, 'shape', options);
19
+ return (
20
+ <>
21
+ <output aria-label={label}>{value}</output>
22
+ <button type="button" onClick={() => setValue('color')}>
23
+ {`set ${label}`}
24
+ </button>
25
+ <button type="button" onClick={() => setValue((prev) => `${prev}+`)}>
26
+ {`append ${label}`}
27
+ </button>
28
+ </>
29
+ );
30
+ }
31
+
32
+ const P = labPrefix('k');
33
+ const shown = (label = 'tab') => screen.getByLabelText(label).textContent;
34
+
35
+ function persisted(backing: Map<string, unknown>, children: React.ReactNode) {
36
+ return (
37
+ <Persistence storageKey="k" storage={createMemoryAdapter(backing)} fallback={<p>loading</p>}>
38
+ {children}
39
+ </Persistence>
40
+ );
41
+ }
42
+
43
+ describe('usePersistedState', () => {
44
+ it('is plain useState with no provider above it', () => {
45
+ render(<Tab />);
46
+ act(() => screen.getByText('set tab').click());
47
+ expect(shown()).toBe('color');
48
+ });
49
+
50
+ it('is plain useState, writing nothing, for a null name', async () => {
51
+ const backing = new Map<string, unknown>();
52
+ const view = render(persisted(backing, <Tab name={null} />));
53
+ await screen.findByLabelText('tab');
54
+ act(() => screen.getByText('set tab').click());
55
+ expect(shown()).toBe('color');
56
+ view.unmount();
57
+ await new Promise((r) => setTimeout(r, 20));
58
+ expect(backing.size).toBe(0);
59
+ });
60
+
61
+ it('keeps its value across a remount, lab-scoped outside a trial', async () => {
62
+ const backing = new Map<string, unknown>();
63
+ const first = render(persisted(backing, <Tab />));
64
+ expect(screen.getByText('loading')).toBeInTheDocument();
65
+ await screen.findByLabelText('tab');
66
+ expect(shown()).toBe('shape');
67
+ act(() => screen.getByText('set tab').click());
68
+ act(() => screen.getByText('append tab').click());
69
+ expect(shown()).toBe('color+');
70
+ first.unmount();
71
+ await waitFor(() => expect(backing.get(`${P}value:lab:tab`)).toBe('color+'));
72
+
73
+ render(persisted(backing, <Tab />));
74
+ await screen.findByLabelText('tab');
75
+ expect(shown()).toBe('color+');
76
+ });
77
+
78
+ it('scopes to the trial it is in, unless asked for the lab', async () => {
79
+ const backing = new Map<string, unknown>();
80
+ render(
81
+ <SingletonExperimentProvider
82
+ id="t:1"
83
+ initialConfig={{}}
84
+ initialState={{}}
85
+ storageKey="k"
86
+ storage={createMemoryAdapter(backing)}
87
+ >
88
+ <Tab label="own" />
89
+ <Tab label="shared" name="shared" options={{ scope: 'lab' }} />
90
+ </SingletonExperimentProvider>,
91
+ );
92
+ await screen.findByLabelText('own');
93
+ act(() => screen.getByText('set own').click());
94
+ act(() => screen.getByText('set shared').click());
95
+ await waitFor(() => expect(backing.get(`${P}value:trial:t%3A1:tab`)).toBe('color'));
96
+ await waitFor(() => expect(backing.get(`${P}value:lab:shared`)).toBe('color'));
97
+ });
98
+
99
+ it('keeps two components with one name in step', async () => {
100
+ const backing = new Map<string, unknown>();
101
+ render(
102
+ persisted(
103
+ backing,
104
+ <>
105
+ <Tab label="a" />
106
+ <Tab label="b" />
107
+ </>,
108
+ ),
109
+ );
110
+ await screen.findByLabelText('a');
111
+ act(() => screen.getByText('set a').click());
112
+ expect(shown('b')).toBe('color');
113
+ });
114
+
115
+ it("re-renders with another tab's write", async () => {
116
+ const backing = new Map<string, unknown>();
117
+ render(persisted(backing, <Tab />));
118
+ await screen.findByLabelText('tab');
119
+ await act(async () => {
120
+ await createMemoryAdapter(backing).set(`${P}value:lab:tab`, 'from elsewhere');
121
+ await new Promise((r) => setTimeout(r, 0));
122
+ });
123
+ expect(shown()).toBe('from elsewhere');
124
+ });
125
+ });
@@ -0,0 +1,66 @@
1
+ import {
2
+ type Dispatch,
3
+ type SetStateAction,
4
+ useCallback,
5
+ useContext,
6
+ useState,
7
+ useSyncExternalStore,
8
+ } from 'react';
9
+ import { TrialIdContext } from './context';
10
+ import { valueRecord } from './labRecords';
11
+ import { PersistenceContext } from './Persistence';
12
+
13
+ /** Options for `usePersistedState`. */
14
+ export interface PersistedStateOptions {
15
+ /** `'trial'` — the default inside a trial — keeps a value per trial, and
16
+ * closing the trial deletes it. `'lab'` shares one value across the lab. */
17
+ scope?: 'trial' | 'lab';
18
+ }
19
+
20
+ /**
21
+ * `useState` whose value survives a reload: a widget's open tab, a panel's
22
+ * position. Inside a `<Lab>`, `SingletonExperimentProvider` or `<Persistence>`
23
+ * the value is a record, loaded before first render and re-rendered when
24
+ * another tab changes it. With no provider above it, or a null `name`, it is
25
+ * plain `useState` — so a component can offer optional persistence and still
26
+ * mount anywhere.
27
+ *
28
+ * Values must be structured-clone values; the JSON-backed adapters warn about
29
+ * one JSON would change. Snapshots do not include them.
30
+ */
31
+ export function usePersistedState<T>(
32
+ name: string | null | undefined,
33
+ initial: T | (() => T),
34
+ options: PersistedStateOptions = {},
35
+ ): [T, Dispatch<SetStateAction<T>>] {
36
+ const records = useContext(PersistenceContext);
37
+ const trialId = useContext(TrialIdContext);
38
+ const [local, setLocal] = useState(initial);
39
+
40
+ const record =
41
+ records && name != null ? valueRecord(options.scope === 'lab' ? null : trialId, name) : null;
42
+
43
+ const subscribe = useCallback(
44
+ (notify: () => void) =>
45
+ records && record
46
+ ? records.subscribe((changes) => {
47
+ if (changes.some((c) => c.name === record)) notify();
48
+ })
49
+ : () => {},
50
+ [records, record],
51
+ );
52
+ const read = (): T =>
53
+ records && record && records.has(record) ? (records.get(record) as T) : local;
54
+ const persisted = useSyncExternalStore(subscribe, read, read);
55
+
56
+ const setPersisted = useCallback<Dispatch<SetStateAction<T>>>(
57
+ (next) => {
58
+ if (!records || !record) return;
59
+ const prev = records.has(record) ? (records.get(record) as T) : local;
60
+ records.set(record, typeof next === 'function' ? (next as (p: T) => T)(prev) : next);
61
+ },
62
+ [records, record, local],
63
+ );
64
+
65
+ return records && record ? [persisted, setPersisted] : [local, setLocal];
66
+ }
@@ -1,7 +1,10 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { describe, expect, it } from 'vitest';
4
- import { createMemoryAdapter } from './adapters';
4
+ import { auto } from '../config/auto';
5
+ import { f } from '../config/builder';
6
+ import { defineInstrument } from '../instrument/defineInstrument';
7
+ import type { Instrument } from '../instrument/types';
5
8
  import { LabStoreProvider, TrialIdProvider } from './context';
6
9
  import { createLabStore } from './store';
7
10
  import { useTrialState } from './useTrialState';
@@ -10,7 +13,7 @@ type TestState = { count: number };
10
13
  type TestConfig = { step: number };
11
14
 
12
15
  function makeWrapper(trialId: string) {
13
- const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
16
+ const store = createLabStore();
14
17
  store.getState().addTrial({
15
18
  id: trialId,
16
19
  instrumentName: 'Counter',
@@ -59,7 +62,7 @@ describe('useTrialState', () => {
59
62
  });
60
63
 
61
64
  it('sibling trial does not re-render on unrelated trial changes', () => {
62
- const store = createLabStore({ storageKey: 'test', storage: createMemoryAdapter() });
65
+ const store = createLabStore();
63
66
  store.getState().addTrial({
64
67
  id: 'w1',
65
68
  instrumentName: 'T',
@@ -100,3 +103,84 @@ describe('useTrialState', () => {
100
103
  expect(() => renderHook(() => useTrialState())).toThrow('[labkit]');
101
104
  });
102
105
  });
106
+
107
+ const autoInstrument = defineInstrument({
108
+ name: 'Auto',
109
+ config: f.schema({
110
+ width: f.number(432),
111
+ gap: f.number(12).auto((c) => (c.width as number) / 24),
112
+ }),
113
+ initialState: () => ({ count: 0 }),
114
+ render: () => null,
115
+ });
116
+
117
+ function makeAutoWrapper(trialId: string) {
118
+ const store = createLabStore({ instruments: [autoInstrument as Instrument] });
119
+ store.getState().addTrial({
120
+ id: trialId,
121
+ instrumentName: 'Auto',
122
+ config: autoInstrument.defaultConfig(),
123
+ state: { count: 0 },
124
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
125
+ });
126
+ return {
127
+ store,
128
+ wrapper: ({ children }: { children: ReactNode }) => (
129
+ <LabStoreProvider store={store}>
130
+ <TrialIdProvider trialId={trialId}>{children}</TrialIdProvider>
131
+ </LabStoreProvider>
132
+ ),
133
+ };
134
+ }
135
+
136
+ describe('auto paths', () => {
137
+ it('hands the instrument the resolved config and the raw one separately', () => {
138
+ const { store, wrapper } = makeAutoWrapper('w1');
139
+ const { result } = renderHook(
140
+ () => useTrialState<{ count: number }, { width: number; gap: number }>(),
141
+ { wrapper },
142
+ );
143
+ act(() => store.getState().updateTrialConfig('w1', 'gap', auto));
144
+ expect(result.current.config.gap).toBe(18);
145
+ expect(result.current.raw.gap).toBe(12);
146
+ expect([...result.current.auto]).toEqual(['gap']);
147
+ });
148
+
149
+ it('leaves a pinned config untouched', () => {
150
+ const { wrapper } = makeAutoWrapper('w1');
151
+ const { result } = renderHook(
152
+ () => useTrialState<{ count: number }, { width: number; gap: number }>(),
153
+ { wrapper },
154
+ );
155
+ expect(result.current.config.gap).toBe(12);
156
+ expect(result.current.config).toBe(result.current.raw);
157
+ expect([...result.current.auto]).toEqual([]);
158
+ });
159
+
160
+ it('records a path written as auto instead of storing the sentinel', () => {
161
+ const { store } = makeAutoWrapper('w1');
162
+ store.getState().updateTrialConfig('w1', 'gap', 24);
163
+ store.getState().updateTrialConfig('w1', 'gap', auto);
164
+ const rec = store.getState().trials[0];
165
+ expect(rec?.auto).toEqual(['gap']);
166
+ // The last pinned value survives, so un-pinning is lossless.
167
+ expect(rec?.config).toEqual({ width: 432, gap: 24 });
168
+ });
169
+
170
+ it('pins again when a real value is written to an auto path', () => {
171
+ const { store } = makeAutoWrapper('w1');
172
+ store.getState().updateTrialConfig('w1', 'gap', auto);
173
+ store.getState().updateTrialConfig('w1', 'gap', 30);
174
+ const rec = store.getState().trials[0];
175
+ expect(rec?.auto).toBeUndefined();
176
+ expect((rec?.config as { gap: number }).gap).toBe(30);
177
+ });
178
+
179
+ it('does not allocate a new record when the path is already auto', () => {
180
+ const { store } = makeAutoWrapper('w1');
181
+ store.getState().updateTrialConfig('w1', 'gap', auto);
182
+ const first = store.getState().trials[0];
183
+ store.getState().updateTrialConfig('w1', 'gap', auto);
184
+ expect(store.getState().trials[0]).toBe(first);
185
+ });
186
+ });
@@ -1,5 +1,6 @@
1
1
  import { useContext } from 'react';
2
2
  import { useStore } from 'zustand/react';
3
+ import { useResolvedConfig } from '../config/useResolvedConfig';
3
4
  import { LabStoreContext, TrialIdContext } from './context';
4
5
  import type { TrialStateHandle } from './types';
5
6
 
@@ -19,10 +20,17 @@ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS
19
20
 
20
21
  const updateTrialState = useStore(ctx.store, (s) => s.updateTrialState);
21
22
  const updateTrialConfig = useStore(ctx.store, (s) => s.updateTrialConfig);
23
+ // The store's own copy, not `useConfigSchema`: that reads `LabContext`, and
24
+ // this hook only has the store's.
25
+ const instrumentHooks = useStore(ctx.store, (s) => s.instrumentHooks);
26
+ const schema = instrumentHooks().configSchemas[record.instrumentName];
27
+ const { config, auto } = useResolvedConfig<TC>(schema, record.config as TC, record.auto);
22
28
 
23
29
  return {
24
30
  state: record.state as TS,
25
- config: record.config as TC,
31
+ config,
32
+ raw: record.config as TC,
33
+ auto,
26
34
  setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
27
35
  setConfig: (path, value) => updateTrialConfig(trialId, path, value),
28
36
  };
@@ -1,7 +1,9 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { ZOOM_FLOOR } from '@weasel-js/core';
2
+ import { describe, expect, it } from 'vitest';
2
3
  import { createMemoryAdapter } from './adapters';
4
+ import { openLabStore } from './openLabStore';
3
5
  import { createLabStore } from './store';
4
- import { as2DView, DEFAULT_VIEW } from './view';
6
+ import { as2DView, DEFAULT_VIEW, normalize2DView, withZoom } from './view';
5
7
 
6
8
  interface OrbitView {
7
9
  yaw: number;
@@ -41,7 +43,7 @@ describe('as2DView', () => {
41
43
 
42
44
  describe('a trial view labkit does not interpret', () => {
43
45
  it('stores and returns a view shape that is not zoom/pan', () => {
44
- const store = createLabStore({ storageKey: 'view-a', storage: createMemoryAdapter() });
46
+ const store = createLabStore();
45
47
  store.getState().addTrial({
46
48
  id: 'w1',
47
49
  instrumentName: 'gem',
@@ -55,28 +57,32 @@ describe('a trial view labkit does not interpret', () => {
55
57
  expect(store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
56
58
  });
57
59
 
58
- it('round-trips that view through persistence', () => {
59
- vi.useFakeTimers();
60
- const mem = createMemoryAdapter();
61
- const seed = createLabStore({ storageKey: 'view-b', storage: mem });
62
- seed.getState().addTrial({
60
+ it('round-trips that view through persistence', async () => {
61
+ const backing = new Map<string, unknown>();
62
+ const seed = await openLabStore({
63
+ storageKey: 'view-b',
64
+ storage: createMemoryAdapter(backing),
65
+ });
66
+ seed.store.getState().addTrial({
63
67
  id: 'w1',
64
68
  instrumentName: 'gem',
65
69
  config: {},
66
70
  state: {},
67
71
  view: orbit,
68
72
  });
69
- vi.advanceTimersByTime(500);
70
- vi.useRealTimers();
73
+ await seed.close();
71
74
 
72
- const hydrated = createLabStore({ storageKey: 'view-b', storage: mem });
73
- expect(hydrated.getState().trials[0]?.view as OrbitView).toEqual(orbit);
75
+ const hydrated = await openLabStore({
76
+ storageKey: 'view-b',
77
+ storage: createMemoryAdapter(backing),
78
+ });
79
+ expect(hydrated.store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
74
80
  });
75
81
  });
76
82
 
77
83
  describe('an unchanged write costs no new record', () => {
78
84
  it('returns the same trial object when setState returns its input', () => {
79
- const store = createLabStore({ storageKey: 'identity-a', storage: createMemoryAdapter() });
85
+ const store = createLabStore();
80
86
  store.getState().addTrial({
81
87
  id: 'w1',
82
88
  instrumentName: 'gem',
@@ -94,7 +100,7 @@ describe('an unchanged write costs no new record', () => {
94
100
  });
95
101
 
96
102
  it('still replaces the record when the state actually changes', () => {
97
- const store = createLabStore({ storageKey: 'identity-b', storage: createMemoryAdapter() });
103
+ const store = createLabStore();
98
104
  store.getState().addTrial({
99
105
  id: 'w1',
100
106
  instrumentName: 'gem',
@@ -110,7 +116,7 @@ describe('an unchanged write costs no new record', () => {
110
116
  });
111
117
 
112
118
  it('returns the same trial object when the view is written unchanged', () => {
113
- const store = createLabStore({ storageKey: 'identity-c', storage: createMemoryAdapter() });
119
+ const store = createLabStore();
114
120
  store.getState().addTrial({
115
121
  id: 'w1',
116
122
  instrumentName: 'gem',
@@ -125,3 +131,31 @@ describe('an unchanged write costs no new record', () => {
125
131
  expect(store.getState().trials[0]).toBe(before);
126
132
  });
127
133
  });
134
+
135
+ describe('2D view zoom invariant', () => {
136
+ it('reads a stored zoom of 0 back as the floor', () => {
137
+ expect(as2DView({ zoom: 0, pan: { x: 1, y: 2 } })).toEqual({
138
+ zoom: ZOOM_FLOOR,
139
+ pan: { x: 1, y: 2 },
140
+ });
141
+ });
142
+
143
+ it('normalizes a NaN zoom and a non-finite pan', () => {
144
+ expect(normalize2DView({ zoom: Number.NaN, pan: { x: Infinity, y: 3 } })).toEqual({
145
+ zoom: ZOOM_FLOOR,
146
+ pan: { x: 0, y: 3 },
147
+ });
148
+ });
149
+
150
+ it('returns a valid view unchanged', () => {
151
+ const v = { zoom: 2, pan: { x: 1, y: 1 } };
152
+ expect(normalize2DView(v)).toBe(v);
153
+ });
154
+
155
+ it('writes a new zoom through the same rule', () => {
156
+ expect(withZoom({ zoom: 2, pan: { x: 4, y: 5 } }, 0)).toEqual({
157
+ zoom: ZOOM_FLOOR,
158
+ pan: { x: 4, y: 5 },
159
+ });
160
+ });
161
+ });
package/src/state/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeZoom } from '@weasel-js/core';
1
2
  import type { ViewTransform } from '../instrument/types';
2
3
 
3
4
  /** The 2D view labkit has always shipped, and what a trial gets when it names no
@@ -14,5 +15,21 @@ export function as2DView(view: unknown): ViewTransform2D | null {
14
15
  if (typeof v.zoom !== 'number') return null;
15
16
  if (typeof v.pan !== 'object' || v.pan === null) return null;
16
17
  if (typeof v.pan.x !== 'number' || typeof v.pan.y !== 'number') return null;
17
- return { zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } };
18
+ return normalize2DView({ zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } });
19
+ }
20
+
21
+ /** `view` inside the invariant every view holds: a zoom that is not a finite
22
+ * number of at least `ZOOM_FLOOR` becomes the floor, and a non-finite pan
23
+ * becomes 0. Returns `view` itself when it already holds. */
24
+ export function normalize2DView(view: ViewTransform2D): ViewTransform2D {
25
+ const zoom = normalizeZoom(view.zoom);
26
+ const x = Number.isFinite(view.pan.x) ? view.pan.x : 0;
27
+ const y = Number.isFinite(view.pan.y) ? view.pan.y : 0;
28
+ if (zoom === view.zoom && x === view.pan.x && y === view.pan.y) return view;
29
+ return { zoom, pan: { x, y } };
30
+ }
31
+
32
+ /** `view` at a new zoom, through the same rule. */
33
+ export function withZoom(view: ViewTransform2D, zoom: number): ViewTransform2D {
34
+ return normalize2DView({ zoom, pan: view.pan });
18
35
  }
package/src/styles.less CHANGED
@@ -5,6 +5,7 @@
5
5
  @import './lab/LabSwitcher.less';
6
6
  @import './primitives/Toolbar.less';
7
7
  @import './primitives/Sidebar.less';
8
+ @import './primitives/Split.less';
8
9
  @import './primitives/ZoomControl.less';
9
10
  @import './primitives/StatusBar.less';
10
11
  @import './primitives/JobProgress.less';
@@ -18,8 +19,10 @@
18
19
  @import './chrome/regions/PaletteRegion.less';
19
20
  @import './controls/ControlPanel.less';
20
21
  @import './canvas/CanvasStack.less';
22
+ @import './canvas/Stage.less';
21
23
  @import './loupe/Loupe.less';
22
24
  @import './layers/LayerList.less';
23
25
  @import './annotations/Annotations.less';
24
26
  @import './dragdrop/Palette.less';
25
27
  @import './dragdrop/DragGhost.less';
28
+ @import './specimen/Specimen.less';
@@ -15,7 +15,7 @@ than a few 3D tiles have to share one surface.
15
15
  | `rect.ts` | The `Rect` and `Box` types |
16
16
  | `composeRects.ts` | Tile boxes into surface-relative rects; both are viewport-relative, so it is a subtraction |
17
17
  | `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
18
- | `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
18
+ | `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR, re-tile clears |
19
19
  | `SurfaceContext.ts` | Carries the handle down |
20
20
  | `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useTileId(id)`, `useSurface()`, `useSurfaceOptional()` |
21
21
 
@@ -38,7 +38,9 @@ const surface = useTiledSurface({
38
38
 
39
39
  `onFrame` carries **every** tile's rect, not only the dirty ones — a scissored
40
40
  draw has to know where it is drawing relative to a surface that may have resized
41
- under it.
41
+ under it. It runs before this frame's painters, and what it invalidates paints
42
+ in the same frame: a buffer the owner just resized is blank, and every tile owes
43
+ a repaint.
42
44
 
43
45
  ## A tile id is scoped to its trial
44
46
 
@@ -61,14 +63,44 @@ one contributes a single rect, and a trial with nothing to draw contributes none
61
63
  - **`preserveDrawingBuffer` is the consumer's job and is usually required.** A
62
64
  partial redraw touches one tile; without it the default framebuffer's contents
63
65
  are undefined after the page composites, and every other tile goes black.
64
- - **Gutters lie outside every scissor.** Clear the whole surface when the tile set
65
- changes, or a re-tile strands the old tiles' pixels between the new ones.
66
- - **A tile that moves without resizing** is already handled: `Workspace`
67
- invalidates rects off the grid's own `node.placementChanged`. A host that moves
68
- something the grid does not know about calls `invalidateRects()` itself.
66
+ - **Gutters lie outside every scissor**, so a tile that moves leaves its old
67
+ picture where nothing will paint over it — a cloned trial strands the whole
68
+ previous layout in the gap. Register a `registerClear(id, fn)` beside the
69
+ painter: every registered clear runs, before any painter, on a frame where
70
+ `frame.retiled` is true. A tenant must not clear from its own painter, because
71
+ tenants paint in sequence and the second would wipe the first.
72
+ - **The owner cannot clear for you.** labkit owns the canvas, never the context,
73
+ and `canvas.width = <its own value>` resizes nothing, so it clears nothing —
74
+ the DOM trick that resets a 2D canvas is a no-op here. Only the context that
75
+ drew the pixels can erase them, which is why the clear is a tenant's to
76
+ register.
77
+ - **There are two buffers, and the default is the wrong one for an opaque
78
+ tenant.** `useSurfaceCanvas()` gives you the buffer *over* the trial DOM,
79
+ which is what a mark annotating an instrument wants and what every tenant
80
+ predating the split asked for. A renderer that fills its tile buries anything
81
+ the pane draws, so it wants `useSurfaceCanvas('under')` and a transparent
82
+ pane. Both buffers share one set of tile rects — rects are measured against
83
+ the surface container either way — so nothing else about a tenant changes.
84
+ - **A tile that moves without resizing** is handled twice over, and it needs
85
+ both. `Workspace` invalidates rects off the grid's own `node.placementChanged`,
86
+ which catches the move being *ordered*; the surface then keeps measuring until
87
+ two measurements agree, which catches where it *lands*. Without the second, a
88
+ panel whose size settles while its position is still animating leaves
89
+ `ResizeObserver` with nothing to report, and the tile paints for the rest of
90
+ its life at the spot it was caught mid-flight. A host that moves something the
91
+ grid does not know about still calls `invalidateRects()` itself.
69
92
 
70
93
  ## Testing
71
94
 
72
95
  No WebGL in the suite. The arithmetic is pure and tested directly; the hook is
73
96
  tested with `getBoundingClientRect` stubbed per element, because jsdom measures
74
97
  everything as zero.
98
+
99
+ Which layer a tenant landed on is not testable here at all — jsdom resolves no
100
+ `z-index`, so `Lab.surface.test.tsx` asserts source order and says in the test
101
+ that it is a proxy. A browser settles it, and the probe has to be a **child of
102
+ the tile's own element**: one appended to `<body>` is outside the stacking
103
+ context the buffers sit in, so it paints over everything and reports success
104
+ whichever buffer the tenant chose. Measured against the 3D lab with a magenta
105
+ div inside `.td-pane` — on `'under'` the div survives, on `'over'` it reads back
106
+ as the viewport's own background.
@@ -4,6 +4,29 @@ import type { SurfaceHandle } from './useTiledSurface';
4
4
  /** Null when no surface owner is above — a lab with no shared surface at all. */
5
5
  export const SurfaceContext = createContext<SurfaceHandle | null>(null);
6
6
 
7
- /** The buffer the surface's tiles paint into. Null until the owner's canvas is
8
- * mounted, and for an owner that keeps its surface unpainted. */
9
- export const SurfaceCanvasContext = createContext<HTMLCanvasElement | null>(null);
7
+ /**
8
+ * Which of the two stacked buffers a tenant paints into.
9
+ *
10
+ * `'over'` sits above the trial DOM: right for marks that annotate an
11
+ * instrument, which is what the surface was built for and so the default.
12
+ * `'under'` sits below it, which is what an opaque renderer needs — a tile that
13
+ * fills its pane hides anything the pane's own DOM draws, so a 3D viewport on
14
+ * the over-buffer has to paint its own chrome into the backend rather than
15
+ * putting a label or a button in the pane.
16
+ *
17
+ * Both exist for the lifetime of the surface and share one set of tile rects,
18
+ * so a lab can hold an annotated 2D trial and an opaque 3D one at once.
19
+ */
20
+ export type SurfaceLayer = 'over' | 'under';
21
+
22
+ /** The two buffers a surface owner publishes. A member is null until the
23
+ * owner's canvas mounts, and for an owner that keeps its surface unpainted. */
24
+ export interface SurfaceCanvases {
25
+ over: HTMLCanvasElement | null;
26
+ under: HTMLCanvasElement | null;
27
+ }
28
+
29
+ const NO_CANVASES: SurfaceCanvases = { over: null, under: null };
30
+
31
+ /** The buffers the surface's tiles paint into. */
32
+ export const SurfaceCanvasContext = createContext<SurfaceCanvases>(NO_CANVASES);
@@ -1,6 +1,7 @@
1
1
  export { composeRects, rectsEqual } from './composeRects';
2
2
  export { toDeviceRect } from './deviceRect';
3
3
  export type { Box, Rect } from './rect';
4
+ export type { SurfaceCanvases, SurfaceLayer } from './SurfaceContext';
4
5
  export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
5
6
  export {
6
7
  useSurface,
@@ -10,6 +11,7 @@ export {
10
11
  useTileId,
11
12
  } from './useSurfaceTile';
12
13
  export type {
14
+ SurfaceClear,
13
15
  SurfaceFrame,
14
16
  SurfaceHandle,
15
17
  TilePainter,
@@ -10,6 +10,7 @@ function fakeHandle(): SurfaceHandle {
10
10
  invalidateAll: vi.fn(),
11
11
  invalidateRects: vi.fn(),
12
12
  registerTile: vi.fn(),
13
+ registerClear: vi.fn(() => () => {}),
13
14
  registerPainter: vi.fn(() => () => {}),
14
15
  containerRef: vi.fn(),
15
16
  getContainer: vi.fn(() => null),
@@ -1,12 +1,13 @@
1
1
  import { useCallback, useContext } from 'react';
2
2
  import { TrialIdContext } from '../state/context';
3
- import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
3
+ import { SurfaceCanvasContext, SurfaceContext, type SurfaceLayer } from './SurfaceContext';
4
4
  import type { SurfaceHandle } from './useTiledSurface';
5
5
 
6
- /** The surface's shared buffer, or null where nothing paints one. What a tile
7
- * hands to `paintInto`. */
8
- export function useSurfaceCanvas(): HTMLCanvasElement | null {
9
- return useContext(SurfaceCanvasContext);
6
+ /** One of the surface's two shared buffers, or null where nothing paints one.
7
+ * What a tile hands to `paintInto`. See {@link SurfaceLayer} for which to ask
8
+ * for; the default is the one over the trial DOM. */
9
+ export function useSurfaceCanvas(layer: SurfaceLayer = 'over'): HTMLCanvasElement | null {
10
+ return useContext(SurfaceCanvasContext)[layer];
10
11
  }
11
12
 
12
13
  /** The surface above, or null. Use this where a surface is genuinely optional. */