@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
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
- // The property-panel family and LayerStack live in `@weasel-js/ui` now; labkit
2
- // re-exports them so existing `@weasel-js/labkit` imports keep resolving. Named,
3
- // not `export *` — a star re-export of an external package emits no binding in
4
- // the bundle.
1
+ // The property-panel family, LayerStack, ToggleBar and Button live in
2
+ // `@weasel-js/ui`; labkit re-exports them so chrome built on labkit needs no
3
+ // direct ui dependency. Named, not `export *` — a star re-export of an external
4
+ // package emits no binding in the bundle.
5
5
  export type {
6
+ ButtonProps,
7
+ ButtonSize,
8
+ ButtonVariant,
6
9
  CheckboxRowProps,
7
10
  ColorRowProps,
8
11
  CurveFieldProps,
@@ -26,9 +29,14 @@ export type {
26
29
  SliderRowProps,
27
30
  SubpanelProps,
28
31
  TextRowProps,
32
+ ToggleBarItem,
33
+ ToggleBarProps,
34
+ ToggleBarSize,
35
+ ToggleBarVariant,
29
36
  ToggleRowProps,
30
37
  } from '@weasel-js/ui';
31
38
  export {
39
+ Button,
32
40
  CheckboxRow,
33
41
  ColorRow,
34
42
  CurveField,
@@ -45,6 +53,7 @@ export {
45
53
  SliderRow,
46
54
  Subpanel,
47
55
  TextRow,
56
+ ToggleBar,
48
57
  ToggleRow,
49
58
  } from '@weasel-js/ui';
50
59
  export type {
@@ -76,7 +85,7 @@ export {
76
85
  createAnnotationScene,
77
86
  createAnnotationStore,
78
87
  fracContains,
79
- fracIntersects,
88
+ fracEncloses,
80
89
  fracToWorld,
81
90
  isStale,
82
91
  roundFrac,
@@ -118,6 +127,7 @@ export type {
118
127
  OrbitHandlers,
119
128
  OrbitView,
120
129
  PanZoomHandlers,
130
+ StageProps,
121
131
  UseOrbitOptions,
122
132
  UsePanZoomOptions,
123
133
  Vec3,
@@ -133,10 +143,12 @@ export {
133
143
  centerOn,
134
144
  clampPitch,
135
145
  DEFAULT_FRAME,
146
+ fitStage,
136
147
  orbitAfterDrag,
137
148
  orbitAfterWheel,
138
149
  PITCH_LIMIT,
139
150
  resolveFrame,
151
+ Stage,
140
152
  screenToWorld,
141
153
  useOrbit,
142
154
  usePanZoom,
@@ -145,7 +157,19 @@ export {
145
157
  zoomAt,
146
158
  } from './canvas';
147
159
  export * from './chrome';
148
- export { f, isConfigBranch } from './config/builder';
160
+ export {
161
+ BaseNode,
162
+ BooleanNode,
163
+ ColorNode,
164
+ CustomNode,
165
+ EnumNode,
166
+ f,
167
+ GroupNode,
168
+ isConfigBranch,
169
+ NumberNode,
170
+ StringNode,
171
+ ValueNode,
172
+ } from './config/builder';
149
173
  export { fromConfigFields } from './config/fromConfigField';
150
174
  export {
151
175
  fillConfigDefaults,
@@ -209,6 +233,7 @@ export type {
209
233
  PaletteItem,
210
234
  Point,
211
235
  RenderContext,
236
+ StageCapability,
212
237
  SystemEvent,
213
238
  UndoCapability,
214
239
  ViewTransform,
@@ -222,7 +247,10 @@ export { LayerList } from './layers';
222
247
  export * from './loupe';
223
248
  export * from './primitives';
224
249
  export {
250
+ createIndexedDbAdapter,
225
251
  createMemoryAdapter,
252
+ type IndexedDbAdapterOptions,
253
+ indexedDbAdapter,
226
254
  localStorageAdapter,
227
255
  noneAdapter,
228
256
  sessionStorageAdapter,
@@ -237,6 +265,9 @@ export {
237
265
  useTrialId,
238
266
  } from './state/context';
239
267
  export { CURRENT_DOCUMENT_VERSION, labDocumentKey, quarantineKey } from './state/document';
268
+ export { type OpenedLabStore, type OpenLabStoreOptions, openLabStore } from './state/openLabStore';
269
+ export { Persistence, type PersistenceProps } from './state/Persistence';
270
+ export type { RecordCache, RecordChange } from './state/records';
240
271
  export type {
241
272
  CreateLabStoreOptions,
242
273
  LabDocument,
@@ -245,6 +276,7 @@ export type {
245
276
  SavedSnapshot,
246
277
  SerializedTrial,
247
278
  StorageAdapter,
279
+ StorageChange,
248
280
  TrialInfo,
249
281
  TrialRecord,
250
282
  TrialStateHandle,
@@ -252,6 +284,7 @@ export type {
252
284
  } from './state/types';
253
285
  export type { UndockedPanel, UndockedPanels } from './state/undock';
254
286
  export { dockPanel, panelKey, undockPanel } from './state/undock';
287
+ export { type PersistedStateOptions, usePersistedState } from './state/usePersistedState';
255
288
  export { useTrialState } from './state/useTrialState';
256
289
  export type { ViewTransform2D } from './state/view';
257
290
  export { as2DView, DEFAULT_VIEW } from './state/view';
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { configMigrationsOf } from './serializers';
3
+ import type { InstrumentList } from './types';
4
+
5
+ const instrument = (name: string, extra: Record<string, unknown> = {}) => ({
6
+ name,
7
+ defaultConfig: () => ({}),
8
+ initialState: () => ({}),
9
+ render: () => null,
10
+ ...extra,
11
+ });
12
+
13
+ describe('configMigrationsOf', () => {
14
+ it('keys each declared migration by instrument name, and skips the rest', () => {
15
+ const migrations = configMigrationsOf([
16
+ instrument('moves', { migrateConfig: (c: unknown) => ({ moved: c }) }),
17
+ instrument('stays'),
18
+ ] as InstrumentList);
19
+ expect(Object.keys(migrations)).toEqual(['moves']);
20
+ expect(migrations.moves?.({ a: 1 })).toEqual({ moved: { a: 1 } });
21
+ });
22
+ });
@@ -12,6 +12,17 @@ export function configDefaultsOf(instruments: InstrumentList): Record<string, ()
12
12
  return out;
13
13
  }
14
14
 
15
+ /** Each instrument's `migrateConfig`, keyed by name, for those that declare one. */
16
+ export function configMigrationsOf(
17
+ instruments: InstrumentList,
18
+ ): Record<string, (stored: unknown) => unknown> {
19
+ const out: Record<string, (stored: unknown) => unknown> = {};
20
+ for (const { name, migrateConfig } of instruments) {
21
+ if (migrateConfig) out[name] = (stored) => migrateConfig(stored);
22
+ }
23
+ return out;
24
+ }
25
+
15
26
  /**
16
27
  * The `serialize` / `deserialize` an instrument list declares, keyed by
17
28
  * instrument name — what `createLabStore` needs to rebuild a trial's state
@@ -67,6 +67,19 @@ export interface CanvasCapability<TS = unknown, TC = unknown> {
67
67
  maxZoom?: number;
68
68
  }
69
69
 
70
+ /** Declares that an instrument's DOM is content of a fixed size, which the
71
+ * trial pans and zooms the way it does a canvas's layers. Not combined with
72
+ * `canvas`: there the DOM is an unzoomed overlay, and `canvas` wins. */
73
+ export interface StageCapability {
74
+ /** The content's own size in CSS pixels, at zoom 1. */
75
+ size: ViewportSize;
76
+ /** Where the view starts. Omitted, the content opens centered and shrunk to
77
+ * fit (`fitStage`). A function is called once the stage knows its size. */
78
+ initialView?: ViewTransform | ((viewport: ViewportSize) => ViewTransform);
79
+ minZoom?: number;
80
+ maxZoom?: number;
81
+ }
82
+
70
83
  /** Declares which of an instrument's layers the trial should offer
71
84
  * show/hide controls for. */
72
85
  export interface LayerCapability {
@@ -99,7 +112,8 @@ export type SystemEvent = string;
99
112
  export type Point = { x: number; y: number };
100
113
  /** What a hit-test found, and where. */
101
114
  export type HitResult = { hit: boolean; layerId?: string; pointId?: string };
102
- /** A trial's camera. */
115
+ /** A trial's camera. `zoom` is always positive and finite once labkit holds
116
+ * it — see `normalize2DView`. */
103
117
  export type ViewTransform = { zoom: number; pan: Point };
104
118
  /** A layer as the layer list shows it. `alwaysOn` layers cannot be hidden. */
105
119
  export type LayerDescriptor = { id: string; label: string; alwaysOn?: boolean };
@@ -119,7 +133,10 @@ export type DragFeedback = { ok: boolean; reason?: string };
119
133
  * is what makes the trial provide the corresponding chrome.
120
134
  */
121
135
  export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
136
+ /** The id a trial record names its instrument by. */
122
137
  name: string;
138
+ /** What a trial of this instrument and the add-trial menu read. Default: `name`. */
139
+ title?: string;
123
140
  defaultConfig: () => TC;
124
141
  initialState: (config: TC) => TS;
125
142
  /** The instrument's config, declared once: values, types and controls.
@@ -133,9 +150,16 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
133
150
  * layers rather than instead of them; return `null` for canvas only. */
134
151
  render: (ctx: RenderContext<TS, TC>) => ReactNode;
135
152
  onConfigChange?: (config: TC, prev: TC, state: TS) => TS;
153
+ /** Moves a stored config's values to where the current schema keeps them —
154
+ * `gridSize` to `grid.size` — before the defaults fill its gaps, so it
155
+ * returns only what it moved and leaves the rest to them. It runs on every
156
+ * read of a stored config, including one already moved, so it must hand
157
+ * back a current config unchanged. */
158
+ migrateConfig?: (stored: unknown) => unknown;
136
159
  serialize?: (state: TS) => unknown;
137
160
  deserialize?: (data: unknown, config: TC) => TS;
138
161
  canvas?: CanvasCapability<TS, TC>;
162
+ stage?: StageCapability;
139
163
  layers?: LayerCapability;
140
164
  dragDrop?: DragDropCapability<TS, TC>;
141
165
  undo?: UndoCapability;
package/src/lab/Lab.less CHANGED
@@ -1,17 +1,8 @@
1
- // `height: 100%` resolves against the containing block, so the lab needs an
2
- // unbroken chain of sized ancestors — and a host that supplies the height but
3
- // not the margin reset gets a page 16px taller than the viewport, which reads
4
- // as a stuck canvas rather than as overflow. Scoped with `:has` so a page that
5
- // mounts no lab is untouched, and stopping at the lab's own parent so an
6
- // embedded lab cannot resize its host's layout.
7
- html:has(.lk-lab),
8
- body:has(.lk-lab) {
1
+ // A fullscreen lab's page is the viewport, and body's default margin would
2
+ // scroll it. `<LabShell>` alone fills the viewport too. `:has` leaves a page
3
+ // that mounts neither untouched.
4
+ body:has(.lk-lab, .lk-shell) {
9
5
  margin: 0;
10
- height: 100%;
11
- }
12
-
13
- body:has(.lk-lab) > :has(> .lk-lab) {
14
- height: 100%;
15
6
  }
16
7
 
17
8
  .lk-lab {
@@ -0,0 +1,147 @@
1
+ import 'fake-indexeddb/auto';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
3
+ import { StrictMode } from 'react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import type { Instrument } from '../instrument/types';
6
+ import { createMemoryAdapter, indexedDbAdapter, resetDefaultStorage } from '../state/adapters';
7
+ import { labPrefix } from '../state/labRecords';
8
+ import type { StorageAdapter } from '../state/types';
9
+ import { Lab } from './Lab';
10
+ import { LabContext, type LabContextValue } from './LabContext';
11
+
12
+ const stub: Instrument = {
13
+ name: 'Stub',
14
+ defaultConfig: () => ({}),
15
+ initialState: () => ({}),
16
+ render: () => <div data-testid="stub-content">stub</div>,
17
+ };
18
+
19
+ let labRef: LabContextValue | null = null;
20
+ function CaptureLab() {
21
+ return (
22
+ <LabContext.Consumer>
23
+ {(value) => {
24
+ labRef = value;
25
+ return null;
26
+ }}
27
+ </LabContext.Consumer>
28
+ );
29
+ }
30
+
31
+ /** A memory adapter whose first `list` waits until released. */
32
+ function gated() {
33
+ const memory = createMemoryAdapter();
34
+ let release = (): void => {};
35
+ const opened = new Promise<void>((resolve) => {
36
+ release = resolve;
37
+ });
38
+ const storage: StorageAdapter = {
39
+ ...memory,
40
+ list: async (prefix) => {
41
+ await opened;
42
+ return memory.list(prefix);
43
+ },
44
+ };
45
+ return { storage, release };
46
+ }
47
+
48
+ afterEach(() => {
49
+ resetDefaultStorage();
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ describe('<Lab> with a storageKey', () => {
54
+ it('renders its fallback until the lab has loaded', async () => {
55
+ const { storage, release } = gated();
56
+ render(
57
+ <Lab
58
+ instruments={[stub]}
59
+ defaultInstrument="Stub"
60
+ storageKey="gate"
61
+ storage={storage}
62
+ fallback={<p>loading</p>}
63
+ />,
64
+ );
65
+ expect(screen.getByText('loading')).toBeInTheDocument();
66
+ expect(screen.queryByTestId('stub-content')).toBeNull();
67
+ await act(async () => release());
68
+ expect(await screen.findByTestId('stub-content')).toBeInTheDocument();
69
+ expect(screen.queryByText('loading')).toBeNull();
70
+ });
71
+
72
+ it("falls back to the lab's empty shell by default", () => {
73
+ const { storage } = gated();
74
+ const { container } = render(
75
+ <Lab
76
+ instruments={[stub]}
77
+ defaultInstrument="Stub"
78
+ storageKey="gate"
79
+ storage={storage}
80
+ title="Waiting"
81
+ />,
82
+ );
83
+ expect(container.querySelector('.lk-lab .lk-shell')).not.toBeNull();
84
+ expect(screen.getByText('Waiting')).toBeInTheDocument();
85
+ expect(screen.queryByRole('region', { name: /trial/i })).toBeNull();
86
+ });
87
+
88
+ it('opens the lab once under StrictMode', async () => {
89
+ const storage = createMemoryAdapter();
90
+ const list = vi.spyOn(storage, 'list');
91
+ render(
92
+ <StrictMode>
93
+ <Lab instruments={[stub]} defaultInstrument="Stub" storageKey="strict" storage={storage} />
94
+ </StrictMode>,
95
+ );
96
+ await screen.findByTestId('stub-content');
97
+ expect(list).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('persists to IndexedDB when given no storage', async () => {
101
+ const view = render(
102
+ <Lab instruments={[stub]} defaultInstrument="Stub" storageKey="idb-lab">
103
+ <CaptureLab />
104
+ </Lab>,
105
+ );
106
+ await screen.findByTestId('stub-content');
107
+ act(() => labRef?.setMode('light'));
108
+ view.unmount();
109
+ await waitFor(async () => {
110
+ const records = new Map(await indexedDbAdapter.list(labPrefix('idb-lab')));
111
+ expect(records.get(`${labPrefix('idb-lab')}meta`)).toMatchObject({ mode: 'light' });
112
+ });
113
+ });
114
+
115
+ it('warns when its storageKey changes after mount, and keeps the first', async () => {
116
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
117
+ const storage = createMemoryAdapter();
118
+ const view = render(
119
+ <Lab instruments={[stub]} defaultInstrument="Stub" storageKey="one" storage={storage} />,
120
+ );
121
+ await screen.findByTestId('stub-content');
122
+ view.rerender(
123
+ <Lab instruments={[stub]} defaultInstrument="Stub" storageKey="two" storage={storage} />,
124
+ );
125
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('`storageKey`'));
126
+ });
127
+
128
+ it('closes the lab on unmount, sending what was queued', async () => {
129
+ const backing = new Map<string, unknown>();
130
+ const view = render(
131
+ <Lab
132
+ instruments={[stub]}
133
+ defaultInstrument="Stub"
134
+ storageKey="close"
135
+ storage={createMemoryAdapter(backing)}
136
+ >
137
+ <CaptureLab />
138
+ </Lab>,
139
+ );
140
+ await screen.findByTestId('stub-content');
141
+ act(() => labRef?.setMode('dark'));
142
+ view.unmount();
143
+ await waitFor(() =>
144
+ expect(backing.get(`${labPrefix('close')}meta`)).toMatchObject({ mode: 'dark' }),
145
+ );
146
+ });
147
+ });
@@ -27,7 +27,6 @@ export const Default: Story = {
27
27
  instruments: [StubInstrument],
28
28
  defaultInstrument: 'Stub',
29
29
  title: 'Default Lab',
30
- storage: null,
31
30
  },
32
31
  };
33
32
 
@@ -44,7 +43,6 @@ export const TwoTrials: Story = {
44
43
  instruments: [StubInstrument],
45
44
  defaultInstrument: 'Stub',
46
45
  title: 'Two Trials',
47
- storage: null,
48
46
  children: <AddSecondTrial />,
49
47
  },
50
48
  };
@@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
12
12
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { defineInstrument } from '../instrument/defineInstrument';
14
14
  import { SurfaceContext } from '../surface/SurfaceContext';
15
- import { useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
15
+ import { useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
16
16
  import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
17
17
  import { useTiledSurface } from '../surface/useTiledSurface';
18
18
  import { Lab } from './Lab';
@@ -43,9 +43,21 @@ let seen: SurfaceHandle | null | 'not-rendered' = 'not-rendered';
43
43
  * trial is scoped by it, and the trial's id is minted by the store. */
44
44
  let paneTileId = 'pane';
45
45
 
46
+ interface SeenCanvases {
47
+ over: HTMLCanvasElement | null;
48
+ under: HTMLCanvasElement | null;
49
+ byDefault: HTMLCanvasElement | null;
50
+ }
51
+ let seenCanvases: SeenCanvases = { over: null, under: null, byDefault: null };
52
+
46
53
  function Probe() {
47
54
  seen = useSurfaceOptional();
48
55
  paneTileId = useTileId('pane');
56
+ seenCanvases = {
57
+ over: useSurfaceCanvas('over'),
58
+ under: useSurfaceCanvas('under'),
59
+ byDefault: useSurfaceCanvas(),
60
+ };
49
61
  const tile = useSurfaceTile('pane');
50
62
  return <div data-testid="pane" ref={tile} />;
51
63
  }
@@ -66,10 +78,34 @@ describe('<Lab> surface provider', () => {
66
78
  expect(typeof (seen as unknown as SurfaceHandle).registerTile).toBe('function');
67
79
  });
68
80
 
69
- it('mounts one inert buffer inside the lab body', () => {
81
+ it('stacks an under and an over buffer inside the lab body', () => {
70
82
  const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
71
83
  const canvases = container.querySelectorAll('.lk-lab__body > canvas.lk-lab__surface');
72
- expect(canvases).toHaveLength(1);
84
+ expect(canvases).toHaveLength(2);
85
+ // Source order, because the z-index that separates them is in a stylesheet
86
+ // jsdom does not resolve — this is a proxy for the stacking, not proof of
87
+ // it. The real check is a browser.
88
+ expect([...canvases].map((c) => c.className)).toEqual([
89
+ 'lk-lab__surface lk-lab__surface--under',
90
+ 'lk-lab__surface lk-lab__surface--over',
91
+ ]);
92
+ });
93
+
94
+ it('hands a tenant whichever of the two it asks for', () => {
95
+ const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
96
+ const [underEl, overEl] = [...container.querySelectorAll('canvas.lk-lab__surface')];
97
+ expect(seenCanvases.over).toBe(overEl);
98
+ expect(seenCanvases.under).toBe(underEl);
99
+ expect(overEl).not.toBe(underEl);
100
+ });
101
+
102
+ it('defaults a tenant to the buffer over the trials', () => {
103
+ // Every tenant predating the split wants the over one: the surface was
104
+ // built for marks that annotate an instrument.
105
+ const { container } = render(<Lab instruments={[probeInstrument]} defaultInstrument="Probe" />);
106
+ const [underEl, overEl] = [...container.querySelectorAll('canvas.lk-lab__surface')];
107
+ expect(seenCanvases.byDefault).toBe(overEl);
108
+ expect(seenCanvases.byDefault).not.toBe(underEl);
73
109
  });
74
110
 
75
111
  it('mounts no buffer of its own when a host already owns the surface', () => {
@@ -165,3 +201,30 @@ describe('<Lab> tile round trip', () => {
165
201
  expect(rect).toEqual({ x: 240, y: 100, w: 320, h: 200 });
166
202
  });
167
203
  });
204
+
205
+ describe('<Lab> pages', () => {
206
+ it('gives the title a way back to the project\'s other labs', () => {
207
+ const { getByRole } = render(
208
+ <Lab
209
+ instruments={[probeInstrument]}
210
+ defaultInstrument="Probe"
211
+ title="kliegsminister"
212
+ path="/kliegsminister"
213
+ pages={[
214
+ { href: '/kliegsminister', label: 'kliegsminister' },
215
+ { href: '/stats', label: 'corpus stats' },
216
+ ]}
217
+ />,
218
+ );
219
+ // A switcher, not a bare heading: the lab it came from has to be reachable.
220
+ expect(getByRole('button', { name: /kliegsminister/ })).toBeTruthy();
221
+ });
222
+
223
+ it('leaves the title a plain heading when there are no other labs', () => {
224
+ const { getByRole, queryByRole } = render(
225
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" title="solo" />,
226
+ );
227
+ expect(getByRole('heading', { name: 'solo' })).toBeTruthy();
228
+ expect(queryByRole('button', { name: /solo/ })).toBeNull();
229
+ });
230
+ });
@@ -1,10 +1,9 @@
1
- import { act, render, screen } from '@testing-library/react';
2
- import type { ReactNode } from 'react';
3
- import { describe, expect, it, vi } from 'vitest';
1
+ import { act, render, screen, waitFor } from '@testing-library/react';
2
+ import { type ReactNode, useEffect } from 'react';
3
+ import { describe, expect, it } from 'vitest';
4
4
  import type { Instrument } from '../instrument/types';
5
5
  import { createMemoryAdapter } from '../state/adapters';
6
- import { labDocumentKey } from '../state/document';
7
- import { Lab } from './Lab';
6
+ import { Lab, type LabProps } from './Lab';
8
7
  import { LabContext, type LabContextValue } from './LabContext';
9
8
 
10
9
  const stub: Instrument = {
@@ -33,10 +32,10 @@ function CaptureLab({ children }: { children?: ReactNode }) {
33
32
  );
34
33
  }
35
34
 
36
- function mountLab(props: Partial<Parameters<typeof Lab>[0]> = {}) {
35
+ function mountLab(props: Partial<Record<keyof LabProps, unknown>> = {}) {
37
36
  labRef = null;
38
37
  return render(
39
- <Lab instruments={[stub, stubB]} defaultInstrument="Stub" {...props}>
38
+ <Lab {...({ instruments: [stub, stubB], defaultInstrument: 'Stub', ...props } as LabProps)}>
40
39
  <CaptureLab />
41
40
  </Lab>,
42
41
  );
@@ -142,9 +141,8 @@ describe('<Lab>', () => {
142
141
  // the only thing positioned to collect them off the instruments. They went
143
142
  // uncollected for months, and an instrument holding anything JSON drops
144
143
  // lost it with no error.
145
- it('gives the store the serializers its instruments declare', () => {
146
- vi.useFakeTimers();
147
- const storage = createMemoryAdapter();
144
+ it('gives the store the serializers its instruments declare', async () => {
145
+ const backing = new Map<string, unknown>();
148
146
  const mapped: Instrument = {
149
147
  ...stub,
150
148
  name: 'Mapped',
@@ -152,15 +150,91 @@ describe('<Lab>', () => {
152
150
  serialize: (state) => ({ seen: [...(state as { seen: Set<string> }).seen] }),
153
151
  deserialize: (data) => ({ seen: new Set((data as { seen: string[] }).seen) }),
154
152
  };
153
+ const storage = () => createMemoryAdapter(backing);
155
154
 
156
- render(
157
- <Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage} storageKey="s" />,
155
+ const first = render(
156
+ <Lab instruments={[mapped]} defaultInstrument="Mapped" storage={storage()} storageKey="s" />,
158
157
  );
159
- act(() => vi.advanceTimersByTime(500));
160
- expect(storage.read(labDocumentKey('s')) ?? '').toContain('"seen":["a"]');
161
- vi.useRealTimers();
162
-
163
- mountLab({ instruments: [mapped], defaultInstrument: 'Mapped', storage, storageKey: 's' });
158
+ await waitFor(() => expect(JSON.stringify([...backing.values()])).toContain('"seen":["a"]'));
159
+ first.unmount();
160
+
161
+ mountLab({
162
+ instruments: [mapped],
163
+ defaultInstrument: 'Mapped',
164
+ storage: storage(),
165
+ storageKey: 's',
166
+ });
167
+ await waitFor(() => expect(labRef?.trials[0]).toBeDefined());
164
168
  expect((labRef?.trials[0]?.state as { seen: Set<string> }).seen).toEqual(new Set(['a']));
165
169
  });
166
170
  });
171
+
172
+ describe('instruments that change while mounted', () => {
173
+ it('fills an open trial’s config before its replaced instrument renders', () => {
174
+ const v1: Instrument = {
175
+ name: 'Grow',
176
+ defaultConfig: () => ({ size: 1 }),
177
+ initialState: () => ({}),
178
+ render: () => null,
179
+ };
180
+ const seenByV2: Array<{ color?: string }> = [];
181
+ const v2: Instrument = {
182
+ ...v1,
183
+ defaultConfig: () => ({ size: 1, color: 'red' }),
184
+ render: ({ config }) => {
185
+ seenByV2.push(config as { color?: string });
186
+ return null;
187
+ },
188
+ };
189
+ const view = render(
190
+ <Lab instruments={[v1]} defaultInstrument="Grow">
191
+ <CaptureLab />
192
+ </Lab>,
193
+ );
194
+ view.rerender(
195
+ <Lab instruments={[v2]} defaultInstrument="Grow">
196
+ <CaptureLab />
197
+ </Lab>,
198
+ );
199
+ expect(labRef?.trials[0]?.config).toEqual({ size: 1, color: 'red' });
200
+ expect(seenByV2.length).toBeGreaterThan(0);
201
+ expect(seenByV2.every((c) => c.color === 'red')).toBe(true);
202
+ });
203
+
204
+ it('opens a trial of an instrument added after mount', () => {
205
+ const later: Instrument = { ...stub, name: 'Later' };
206
+ const view = render(
207
+ <Lab instruments={[stub]} defaultInstrument="Stub">
208
+ <CaptureLab />
209
+ </Lab>,
210
+ );
211
+ view.rerender(
212
+ <Lab instruments={[stub, later]} defaultInstrument="Stub">
213
+ <CaptureLab />
214
+ </Lab>,
215
+ );
216
+ act(() => labRef?.addTrial('Later'));
217
+ expect(labRef?.trials.map((t) => t.instrumentName)).toEqual(['Stub', 'Later']);
218
+ expect(screen.queryByText(/Unknown instrument/)).toBeNull();
219
+ });
220
+
221
+ it('keeps a trial’s content mounted across an instrument swap', () => {
222
+ let mounts = 0;
223
+ function Probe() {
224
+ useEffect(() => {
225
+ mounts += 1;
226
+ }, []);
227
+ return <p>probe</p>;
228
+ }
229
+ const v1: Instrument = {
230
+ name: 'Keep',
231
+ defaultConfig: () => ({}),
232
+ initialState: () => ({}),
233
+ render: () => <Probe />,
234
+ };
235
+ const v2: Instrument = { ...v1, defaultConfig: () => ({ extra: 1 }) };
236
+ const view = render(<Lab instruments={[v1]} defaultInstrument="Keep" />);
237
+ view.rerender(<Lab instruments={[v2]} defaultInstrument="Keep" />);
238
+ expect(mounts).toBe(1);
239
+ });
240
+ });