@weasel-js/labkit 1.4.4 → 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 (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  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-MOM3GOVY.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-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.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-64ZCN3DA.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-U3IYHIAE.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 +110 -381
  53. package/dist/index.js +691 -817
  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 +164 -122
  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 +84 -50
  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/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -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
+ });