@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/lab/Lab.tsx CHANGED
@@ -4,22 +4,37 @@ import {
4
4
  type ReactNode,
5
5
  useCallback,
6
6
  useEffect,
7
+ useLayoutEffect,
7
8
  useMemo,
8
9
  useRef,
9
10
  useState,
10
11
  } from 'react';
11
12
  import { useStore } from 'zustand/react';
13
+ import { AnnotationPreloadContext } from '../annotations/preload';
14
+ import { ANNOTATION_TOOLS } from '../annotations/toolMap';
15
+ import {
16
+ LabFooterRegion,
17
+ LabHeaderRegion,
18
+ LabSidebarRegion,
19
+ labContributions,
20
+ } from '../chrome/LabChrome';
21
+ import type { LabContribution } from '../chrome/labTypes';
12
22
  import type { TrialContribution } from '../chrome/types';
13
23
  import type { ConfigRule, ControlRenderer } from '../config/types';
14
- import { configDefaultsOf, serializersOf } from '../instrument/serializers';
15
24
  import type { InstrumentList } from '../instrument/types';
16
- import { noneAdapter } from '../state/adapters';
25
+ import { Split } from '../primitives/Split';
26
+ import { defaultStorage, noneAdapter } from '../state/adapters';
17
27
  import { LabStoreContext } from '../state/context';
28
+ import { type OpenedLabStore, openLabStore } from '../state/openLabStore';
29
+ import { PersistenceContext } from '../state/Persistence';
30
+ import { createRecordCache } from '../state/records';
18
31
  import { createLabStore, type LabStore } from '../state/store';
19
32
  import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
33
+ import { useOpenOnce, useWarnIgnoredChange } from '../state/useOpenOnce';
34
+ import { usePersistedState } from '../state/usePersistedState';
20
35
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
21
36
  import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
22
- import { useTiledSurface } from '../surface/useTiledSurface';
37
+ import { type SurfaceFrame, useTiledSurface } from '../surface/useTiledSurface';
23
38
  import { interstellarTheme } from '../theme/interstellar';
24
39
  import type { TrialTool } from '../tools/types';
25
40
  import { Trial } from '../trial/Trial';
@@ -30,21 +45,25 @@ import {
30
45
  reorderTrials as reorderTrialsOp,
31
46
  resetTrial as resetTrialOp,
32
47
  } from '../trial/trialOps';
48
+ import { useLabFitWarning } from './fitCheck';
33
49
  import { LabContext, type LabContextValue } from './LabContext';
34
50
  import { LabHeader } from './LabHeader';
35
51
  import { LabPalette } from './LabPalette';
36
52
  import { LabShell } from './LabShell';
53
+ import type { LabPage } from './LabSwitcher';
37
54
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
38
55
  import { useResolvedMode } from './useSystemMode';
39
56
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
40
57
 
41
- /** Props for `<Lab>`. */
42
- export interface LabProps {
58
+ interface LabBaseProps {
59
+ /** May change while mounted. An instrument that is a different object from
60
+ * the last render counts as replaced and its open trials' configs are
61
+ * refilled, so hoist or memoize the list. */
43
62
  instruments: InstrumentList;
44
63
  defaultInstrument: string;
45
- storage?: StorageAdapter | null;
46
- storageKey?: string;
47
64
  mode?: LabMode;
65
+ /** Rendered while a stored lab loads. Default: the lab's empty shell. */
66
+ fallback?: ReactNode;
48
67
  /**
49
68
  * Optional list of CSS colors used to compose the interstellar theme's
50
69
  * cosmic backdrop. Each color becomes one radial-gradient blob on the
@@ -53,12 +72,27 @@ export interface LabProps {
53
72
  */
54
73
  nebula?: readonly string[];
55
74
  title?: string;
75
+ /** The project's other labs. Given two or more, the title becomes the way to
76
+ * reach them — the same switcher `<LabShell>` renders, so a lab reached from
77
+ * one is not a dead end. */
78
+ pages?: readonly LabPage[];
79
+ /** The path the switcher marks as open. Defaults to the current location. */
80
+ path?: string;
56
81
  /** Rendered in the shell's footer, below the workspace. */
57
82
  footer?: ReactNode;
58
83
  /** Contributions added to every trial's chrome, after the instrument's own. */
59
84
  chrome?: readonly TrialContribution[];
85
+ /** Contributions to the lab's own chrome — its header bar, its sidebar, its
86
+ * tool rail and its footer — rather than to every trial's. Rendered after
87
+ * `children` and `footer`, which stay the way a lab drops arbitrary content
88
+ * into those same two boxes. Going from no sidebar contributions to some
89
+ * remounts the workspace and every trial, dropping unpersisted trial state. */
90
+ labChrome?: readonly LabContribution[];
60
91
  /** Built-in contribution ids to drop. Throws on an id that is not there. */
61
92
  suppress?: readonly string[];
93
+ /** Offer the header's add-trial control. Default `true`; a lab that opens
94
+ * trials some other way passes `false`. */
95
+ addTrial?: boolean;
62
96
  /** Tools offered lab-wide. A trial whose instrument declares none of its own
63
97
  * reflects and writes this slot. */
64
98
  tools?: readonly TrialTool[];
@@ -72,31 +106,89 @@ export interface LabProps {
72
106
  children?: ReactNode;
73
107
  }
74
108
 
75
- function buildStore(
109
+ /** Props for `<Lab>`. With a `storageKey` the lab persists — to IndexedDB
110
+ * unless `storage` names another substrate — and both are read once, at
111
+ * mount. Without one, nothing persists. */
112
+ export type LabProps = LabBaseProps &
113
+ (
114
+ | { storageKey?: undefined; storage?: undefined }
115
+ | { storageKey: string; storage?: StorageAdapter }
116
+ );
117
+
118
+ interface OpenedLab extends OpenedLabStore {
119
+ marks: ReadonlyMap<string, unknown>;
120
+ }
121
+
122
+ function seedDefaultTrial(
123
+ store: LabStore,
76
124
  instruments: InstrumentList,
77
125
  defaultInstrument: string,
78
- storage: StorageAdapter,
126
+ ): void {
127
+ if (store.getState().trials.length > 0) return;
128
+ const record = addTrialOp([], instruments, defaultInstrument)[0];
129
+ if (!record) return;
130
+ const { undoStack: _undoStack, ...rest } = record;
131
+ store.getState().addTrial(rest);
132
+ }
133
+
134
+ /** A lab with nothing to load renders at once. Its records hold
135
+ * `usePersistedState` values for the session and write nothing. */
136
+ function openUnstoredLab({ instruments, defaultInstrument, mode }: LabProps): OpenedLab {
137
+ const store = createLabStore({ initialMode: mode ?? 'auto', instruments });
138
+ seedDefaultTrial(store, instruments, defaultInstrument);
139
+ const records = createRecordCache({ storage: noneAdapter, prefix: '', writable: false });
140
+ return { store, records, close: () => records.close(), marks: new Map() };
141
+ }
142
+
143
+ async function openStoredLab(
144
+ { instruments, defaultInstrument, mode }: LabProps,
79
145
  storageKey: string,
80
- initialMode: LabMode,
81
- ): LabStore {
82
- // Hydration reads the serializers, so they go in with the store rather than
83
- // being registered onto it afterwards.
84
- const store = createLabStore({
146
+ storage: StorageAdapter | undefined,
147
+ ): Promise<OpenedLab> {
148
+ // The store reads the instruments' serializers and defaults while it is being
149
+ // built, so they go in with it rather than being pushed onto it afterwards.
150
+ const opened = await openLabStore({
85
151
  storageKey,
86
- storage,
87
- initialMode,
88
- serializers: serializersOf(instruments),
89
- configDefaults: configDefaultsOf(instruments),
152
+ storage: storage ?? (await defaultStorage()),
153
+ initialMode: mode ?? 'auto',
154
+ instruments,
90
155
  });
91
- if (store.getState().trials.length === 0) {
92
- const seeded = addTrialOp([], instruments, defaultInstrument);
93
- const record = seeded[0];
94
- if (record) {
95
- const { undoStack: _undoStack, ...rest } = record;
96
- store.getState().addTrial(rest);
97
- }
98
- }
99
- return store;
156
+ seedDefaultTrial(opened.store, instruments, defaultInstrument);
157
+ const marks = new Map<string, unknown>();
158
+ await Promise.all(
159
+ opened.store.getState().trials.map(async (trial) => {
160
+ const kept = instruments.find((i) => i.name === trial.instrumentName)?.annotations?.storage;
161
+ if (!kept) return;
162
+ try {
163
+ marks.set(trial.id, await kept.load());
164
+ } catch (error) {
165
+ console.warn(`[labkit] could not load the marks of trial "${trial.id}"`, error);
166
+ marks.set(trial.id, null);
167
+ }
168
+ }),
169
+ );
170
+ return { ...opened, marks };
171
+ }
172
+
173
+ function LabFallback({
174
+ title,
175
+ mode,
176
+ pages,
177
+ path,
178
+ }: Pick<LabBaseProps, 'title' | 'mode' | 'pages' | 'path'>) {
179
+ const resolvedMode = useResolvedMode(mode ?? 'auto');
180
+ return (
181
+ <ThemeProvider theme={interstellarTheme} mode={resolvedMode} className="lk-lab">
182
+ <LabShell
183
+ title={title ?? 'Labkit'}
184
+ mode={mode}
185
+ {...(pages ? { pages } : {})}
186
+ {...(path !== undefined ? { path } : {})}
187
+ >
188
+ {null}
189
+ </LabShell>
190
+ </ThemeProvider>
191
+ );
100
192
  }
101
193
 
102
194
  // Fixed spread of nebula blob positions / sizes / fall-off stops. Colors
@@ -120,39 +212,87 @@ function buildNebula(colors: readonly string[]): string {
120
212
  return blobs.join(', ');
121
213
  }
122
214
 
123
- /** The lab runtime: creates the store, provides it, and renders one trial
215
+ /** The strip holding the sidebar beside the tool rail and workspace. Its own component
216
+ * because the width is a persisted value, and `LabRuntime` renders the
217
+ * persistence provider it reads from. */
218
+ function LabPanes({
219
+ contributions,
220
+ children,
221
+ }: {
222
+ contributions: readonly LabContribution[];
223
+ children: ReactNode;
224
+ }) {
225
+ const surface = useSurfaceOptional();
226
+ const [sidebarWidth, setSidebarWidth] = usePersistedState<number | undefined>(
227
+ 'lk-lab-sidebar-width',
228
+ undefined,
229
+ { scope: 'lab' },
230
+ );
231
+ return (
232
+ <Split
233
+ className="lk-lab__panes"
234
+ sidebarClassName="lk-lab__sidebar"
235
+ contentClassName="lk-lab__main"
236
+ label="Lab sidebar"
237
+ defaultWidth={260}
238
+ width={sidebarWidth}
239
+ onWidthChange={(w) => {
240
+ setSidebarWidth(w);
241
+ surface?.invalidateRects();
242
+ }}
243
+ sidebar={<LabSidebarRegion contributions={contributions} />}
244
+ >
245
+ {children}
246
+ </Split>
247
+ );
248
+ }
249
+
250
+ /** The lab runtime: opens the store, provides it, and renders one trial
124
251
  * per record in a grid. Each trial runs one of `instruments`. */
125
- export function Lab({
252
+ export function Lab(props: LabProps) {
253
+ if (process.env.NODE_ENV !== 'production' && props.instruments.length === 0) {
254
+ throw new Error('[labkit] <Lab> requires a non-empty `instruments` array');
255
+ }
256
+ const { storageKey, storage } = props;
257
+ useWarnIgnoredChange('<Lab>', { storageKey, storage });
258
+ const opened = useOpenOnce<OpenedLab>(() =>
259
+ storageKey === undefined ? openUnstoredLab(props) : openStoredLab(props, storageKey, storage),
260
+ );
261
+ if (!opened) {
262
+ return props.fallback !== undefined ? (
263
+ <>{props.fallback}</>
264
+ ) : (
265
+ <LabFallback title={props.title} mode={props.mode} pages={props.pages} path={props.path} />
266
+ );
267
+ }
268
+ return <LabRuntime {...props} opened={opened} />;
269
+ }
270
+
271
+ function LabRuntime({
126
272
  instruments,
127
- defaultInstrument,
128
- storage,
129
- storageKey,
130
273
  mode,
131
274
  nebula,
132
275
  title,
276
+ pages,
277
+ path,
133
278
  footer,
134
279
  chrome,
280
+ labChrome,
135
281
  suppress,
282
+ addTrial,
136
283
  tools,
137
284
  configRules,
138
285
  controls,
139
286
  children,
140
- }: LabProps) {
141
- if (process.env.NODE_ENV !== 'production' && instruments.length === 0) {
142
- throw new Error('[labkit] <Lab> requires a non-empty `instruments` array');
143
- }
287
+ opened,
288
+ }: LabProps & { opened: OpenedLab }) {
289
+ const { store } = opened;
144
290
 
145
- const storeRef = useRef<LabStore | null>(null);
146
- if (storeRef.current === null) {
147
- storeRef.current = buildStore(
148
- instruments,
149
- defaultInstrument,
150
- storage ?? noneAdapter,
151
- storageKey ?? 'labkit',
152
- mode ?? 'auto',
153
- );
154
- }
155
- const store = storeRef.current;
291
+ // The store opened against the list <Lab> mounted with; a later list reaches
292
+ // it here, before paint.
293
+ useLayoutEffect(() => {
294
+ store.getState().setInstruments(instruments);
295
+ }, [instruments, store]);
156
296
 
157
297
  const trials = useStore(store, (s) => s.trials);
158
298
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
@@ -167,38 +307,64 @@ export function Lab({
167
307
  if (panelHostsRef.current === null) panelHostsRef.current = createPanelHostRegistry();
168
308
 
169
309
  // One shared drawing surface for the whole lab, anchored to the body — tile
170
- // rects compose against it, and every tile paints into the one buffer.
310
+ // rects compose against it, and both buffers compose against the same rects.
171
311
  // `Workspace` invalidates rects when the grid moves something a
172
312
  // ResizeObserver cannot see. A host that already owns a surface keeps it: a
173
313
  // lab embedded in a larger shared-surface app must not open a second GL
174
314
  // tenancy, and mounts no buffer of its own.
175
315
  const outerSurface = useSurfaceOptional();
176
- const outerCanvas = useSurfaceCanvas();
177
- const [ownCanvas, setOwnCanvas] = useState<HTMLCanvasElement | null>(null);
178
- const ownCanvasRef = useRef<HTMLCanvasElement | null>(null);
316
+ const outerOver = useSurfaceCanvas('over');
317
+ const outerUnder = useSurfaceCanvas('under');
318
+ const [ownOver, setOwnOver] = useState<HTMLCanvasElement | null>(null);
319
+ const [ownUnder, setOwnUnder] = useState<HTMLCanvasElement | null>(null);
320
+ const ownOverRef = useRef<HTMLCanvasElement | null>(null);
321
+ const ownUnderRef = useRef<HTMLCanvasElement | null>(null);
179
322
  const bufferRef = useRef({ w: 0, h: 0 });
180
323
  const surfaceRef = useRef<ReturnType<typeof useTiledSurface> | null>(null);
181
324
 
182
325
  // Sizing the buffer clears all of it, so every tile has to repaint — not
183
- // only the one whose move triggered the measurement.
184
- const onFrame = useCallback((frame: { size: { width: number; height: number }; dpr: number }) => {
185
- const c = ownCanvasRef.current;
186
- if (!c) return;
326
+ // only the one whose move triggered the measurement. Both buffers are sized
327
+ // together off one comparison: they are the same box, so a tenant of either
328
+ // is looking at the same rects, and one invalidateAll covers both.
329
+ const onFrame = useCallback((frame: SurfaceFrame) => {
330
+ const canvases = [ownUnderRef.current, ownOverRef.current].filter((c) => c !== null);
331
+ if (canvases.length === 0) return;
187
332
  const w = Math.round(frame.size.width * frame.dpr);
188
333
  const h = Math.round(frame.size.height * frame.dpr);
189
- if (bufferRef.current.w === w && bufferRef.current.h === h) return;
190
- bufferRef.current = { w, h };
191
- c.width = w;
192
- c.height = h;
193
- c.style.width = `${frame.size.width}px`;
194
- c.style.height = `${frame.size.height}px`;
195
- surfaceRef.current?.invalidateAll();
334
+ if (bufferRef.current.w !== w || bufferRef.current.h !== h) {
335
+ bufferRef.current = { w, h };
336
+ for (const c of canvases) {
337
+ c.width = w;
338
+ c.height = h;
339
+ c.style.width = `${frame.size.width}px`;
340
+ c.style.height = `${frame.size.height}px`;
341
+ }
342
+ surfaceRef.current?.invalidateAll();
343
+ return;
344
+ }
345
+ // A same-size re-tile does not go through here: assigning `width` its own
346
+ // value resizes nothing, so it clears nothing. The tenants clear, through
347
+ // `registerClear`.
196
348
  }, []);
197
349
 
198
350
  const ownSurface = useTiledSurface({ onFrame });
199
351
  surfaceRef.current = ownSurface;
352
+ const [labBody, setLabBody] = useState<HTMLDivElement | null>(null);
353
+ const attachOwnSurface = ownSurface.containerRef;
354
+ const labBodyRef = useCallback(
355
+ (el: HTMLDivElement | null) => {
356
+ setLabBody(el);
357
+ if (!outerSurface) attachOwnSurface(el);
358
+ },
359
+ [outerSurface, attachOwnSurface],
360
+ );
361
+ useLabFitWarning(labBody);
200
362
  const surface = outerSurface ?? ownSurface;
201
- const surfaceCanvas = outerSurface ? outerCanvas : ownCanvas;
363
+ const surfaceCanvases = useMemo(
364
+ () =>
365
+ outerSurface ? { over: outerOver, under: outerUnder } : { over: ownOver, under: ownUnder },
366
+ [outerSurface, outerOver, outerUnder, ownOver, ownUnder],
367
+ );
202
368
 
203
369
  const workspacePanels = useMemo<PanelDescriptor[]>(
204
370
  () =>
@@ -211,6 +377,19 @@ export function Lab({
211
377
  );
212
378
  const resolvedMode = useResolvedMode(modeValue);
213
379
 
380
+ // Tools and lab contributions share one id namespace, so they merge once
381
+ // here — before any region renders — and a collision throws rather than one
382
+ // of them silently losing.
383
+ // Declaring `annotations` on any instrument puts the drawing tools here, in
384
+ // the lab's rail: one tool is armed across every trial.
385
+ const annotates = instruments.some((i) => i.annotations != null);
386
+ const labChromeAll = useMemo(
387
+ () => labContributions([...(annotates ? ANNOTATION_TOOLS : []), ...(tools ?? [])], labChrome),
388
+ [annotates, tools, labChrome],
389
+ );
390
+ const hasFooterChrome = labChromeAll.some((c) => c.region === 'footer');
391
+ const hasSidebarChrome = labChromeAll.some((c) => c.region === 'sidebar');
392
+
214
393
  useEffect(() => {
215
394
  if (mode && mode !== store.getState().mode) {
216
395
  store.getState().setMode(mode);
@@ -222,8 +401,6 @@ export function Lab({
222
401
  const currentById = new Map(store.getState().trials.map((w) => [w.id, w]));
223
402
  const merged = next.map((w) => currentById.get(w.id) ?? w);
224
403
  store.setState({ trials: merged });
225
- // Trigger persistence flush via a tracked action.
226
- store.getState().setMode(store.getState().mode);
227
404
  };
228
405
 
229
406
  return {
@@ -255,7 +432,6 @@ export function Lab({
255
432
  : w,
256
433
  ),
257
434
  }));
258
- store.getState().updateTrialView(id, record.view);
259
435
  },
260
436
  savedSnapshots,
261
437
  saveSnapshot: (trialId, name) => {
@@ -284,66 +460,103 @@ export function Lab({
284
460
  ? ({ ['--wzl-backdrop' as string]: buildNebula(nebula) } as CSSProperties)
285
461
  : undefined;
286
462
 
463
+ const workspace = (
464
+ <Workspace
465
+ panels={workspacePanels}
466
+ ids={trials.map((w) => w.id)}
467
+ resizable
468
+ reorderable
469
+ onReorder={(ids) => contextValue.reorderTrials(ids)}
470
+ layout={layout as TrialLayout}
471
+ onLayoutChange={(next) => store.getState().setLayout(next)}
472
+ >
473
+ {trials.map((w) => (
474
+ <Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
475
+ ))}
476
+ </Workspace>
477
+ );
478
+
287
479
  return (
288
480
  <LabStoreContext.Provider value={{ store }}>
289
- <LabContext.Provider value={contextValue}>
290
- <ThemeProvider
291
- theme={interstellarTheme}
292
- mode={resolvedMode}
293
- className="lk-lab"
294
- style={backdropStyle}
295
- >
296
- <LabShell
297
- title={title ?? 'Labkit'}
298
- mode={modeValue}
299
- footer={footer}
300
- header={
301
- <>
302
- <LabHeader />
303
- {children}
304
- </>
305
- }
306
- >
307
- <PanelHostContext.Provider value={panelHostsRef.current}>
308
- <SurfaceContext.Provider value={surface}>
309
- <SurfaceCanvasContext.Provider value={surfaceCanvas}>
310
- <div
311
- className="lk-lab__body"
312
- ref={outerSurface ? undefined : ownSurface.containerRef}
313
- >
314
- {outerSurface ? null : (
315
- // Above the trials and inert: the marks a tile paints have
316
- // to sit over the instrument's own DOM, and nothing on this
317
- // buffer takes input — each tile has its own input box.
318
- <canvas
319
- className="lk-lab__surface"
320
- ref={(el) => {
321
- ownCanvasRef.current = el;
322
- setOwnCanvas(el);
323
- }}
324
- />
325
- )}
326
- {tools ? <LabPalette tools={tools} /> : null}
327
- <Workspace
328
- panels={workspacePanels}
329
- ids={trials.map((w) => w.id)}
330
- resizable
331
- reorderable
332
- onReorder={(ids) => contextValue.reorderTrials(ids)}
333
- layout={layout as TrialLayout}
334
- onLayoutChange={(next) => store.getState().setLayout(next)}
335
- >
336
- {trials.map((w) => (
337
- <Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
338
- ))}
339
- </Workspace>
340
- </div>
341
- </SurfaceCanvasContext.Provider>
342
- </SurfaceContext.Provider>
343
- </PanelHostContext.Provider>
344
- </LabShell>
345
- </ThemeProvider>
346
- </LabContext.Provider>
481
+ <PersistenceContext.Provider value={opened.records}>
482
+ <AnnotationPreloadContext.Provider value={opened.marks}>
483
+ <LabContext.Provider value={contextValue}>
484
+ <ThemeProvider
485
+ theme={interstellarTheme}
486
+ mode={resolvedMode}
487
+ className="lk-lab"
488
+ style={backdropStyle}
489
+ >
490
+ <LabShell
491
+ title={title ?? 'Labkit'}
492
+ mode={modeValue}
493
+ {...(pages ? { pages } : {})}
494
+ {...(path !== undefined ? { path } : {})}
495
+ footer={
496
+ hasFooterChrome ? (
497
+ <>
498
+ {footer}
499
+ <LabFooterRegion contributions={labChromeAll} />
500
+ </>
501
+ ) : (
502
+ footer
503
+ )
504
+ }
505
+ header={
506
+ <>
507
+ <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
508
+ {children}
509
+ <LabHeaderRegion contributions={labChromeAll} />
510
+ </>
511
+ }
512
+ >
513
+ <PanelHostContext.Provider value={panelHostsRef.current}>
514
+ <SurfaceContext.Provider value={surface}>
515
+ <SurfaceCanvasContext.Provider value={surfaceCanvases}>
516
+ <div className="lk-lab__body" ref={labBodyRef}>
517
+ {outerSurface ? null : (
518
+ // Two buffers stacked around the trials, both inert —
519
+ // each tile takes input from its own box. A tile's marks
520
+ // annotate the instrument's DOM from over it; an opaque
521
+ // renderer sits under it, so the pane can still hold a
522
+ // label. The under one is first so it paints first.
523
+ <>
524
+ <canvas
525
+ className="lk-lab__surface lk-lab__surface--under"
526
+ ref={(el) => {
527
+ ownUnderRef.current = el;
528
+ setOwnUnder(el);
529
+ }}
530
+ />
531
+ <canvas
532
+ className="lk-lab__surface lk-lab__surface--over"
533
+ ref={(el) => {
534
+ ownOverRef.current = el;
535
+ setOwnOver(el);
536
+ }}
537
+ />
538
+ </>
539
+ )}
540
+ {hasSidebarChrome ? (
541
+ <LabPanes contributions={labChromeAll}>
542
+ <LabPalette contributions={labChromeAll} />
543
+ {workspace}
544
+ </LabPanes>
545
+ ) : (
546
+ <>
547
+ <LabPalette contributions={labChromeAll} />
548
+ {workspace}
549
+ </>
550
+ )}
551
+ </div>
552
+ </SurfaceCanvasContext.Provider>
553
+ </SurfaceContext.Provider>
554
+ </PanelHostContext.Provider>
555
+ </LabShell>
556
+ </ThemeProvider>
557
+ </LabContext.Provider>
558
+ </AnnotationPreloadContext.Provider>
559
+ </PersistenceContext.Provider>
347
560
  </LabStoreContext.Provider>
348
561
  );
349
562
  }
@@ -0,0 +1,18 @@
1
+ // Host pages for LabFit.stories. Each is scoped by its own class, so a story
2
+ // that mounts no fixture leaves the page untouched.
3
+
4
+ // The mount every example documents: `html, body, #root { margin: 0; height: 100% }`.
5
+ html:has(> body > .lk-fit-host--reset),
6
+ body:has(> .lk-fit-host--reset),
7
+ .lk-fit-host--reset {
8
+ margin: 0;
9
+ height: 100%;
10
+ }
11
+
12
+ .lk-fit-heading {
13
+ height: 40px;
14
+ }
15
+
16
+ .lk-fit-frame {
17
+ height: 560px;
18
+ }