@weasel-js/labkit 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +24 -0
  2. package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
  3. package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
  4. package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
  5. package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
  6. package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
  7. package/dist/canvas/index.d.ts +4 -4
  8. package/dist/canvas/index.js +2 -2
  9. package/dist/chrome/index.d.ts +2 -2
  10. package/dist/chrome/index.js +7 -7
  11. package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
  12. package/dist/chunk-4RCVWX4M.js.map +1 -0
  13. package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
  14. package/dist/chunk-6IIKLXBZ.js.map +1 -0
  15. package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
  16. package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
  17. package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
  18. package/dist/chunk-7ALJOVGP.js.map +1 -0
  19. package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
  20. package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
  21. package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
  22. package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
  23. package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
  24. package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
  25. package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
  26. package/dist/chunk-KHB3KRRQ.js.map +1 -0
  27. package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
  28. package/dist/chunk-NHIO2O3O.js.map +1 -0
  29. package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
  30. package/dist/chunk-QMAY74TP.js.map +1 -0
  31. package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
  32. package/dist/chunk-RN7T7OBO.js.map +1 -0
  33. package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
  34. package/dist/chunk-RXO5UEGT.js.map +1 -0
  35. package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
  36. package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
  37. package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
  38. package/dist/chunk-T5OEUNCB.js.map +1 -0
  39. package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
  40. package/dist/chunk-UX66USTN.js.map +1 -0
  41. package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
  42. package/dist/chunk-V4SMGEYY.js.map +1 -0
  43. package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
  44. package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
  45. package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
  46. package/dist/chunk-XWR2P6MR.js.map +1 -0
  47. package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
  48. package/dist/chunk-YZA73U4A.js.map +1 -0
  49. package/dist/config/index.d.ts +2 -2
  50. package/dist/config/index.js +2 -2
  51. package/dist/controls/index.js +5 -5
  52. package/dist/dragdrop/index.d.ts +1 -1
  53. package/dist/dragdrop/index.js +2 -2
  54. package/dist/index.d.ts +157 -15
  55. package/dist/index.js +893 -258
  56. package/dist/index.js.map +1 -1
  57. package/dist/layers/index.d.ts +1 -1
  58. package/dist/layers/index.js +3 -3
  59. package/dist/loupe/index.d.ts +175 -27
  60. package/dist/loupe/index.js +547 -141
  61. package/dist/loupe/index.js.map +1 -1
  62. package/dist/overview/index.js +5 -5
  63. package/dist/passthrough/weasel-ui.js +3 -3
  64. package/dist/primitives/index.d.ts +4 -19
  65. package/dist/primitives/index.js +7 -7
  66. package/dist/state/index.d.ts +5 -7
  67. package/dist/state/index.js +5 -5
  68. package/dist/styles.css +173 -38
  69. package/dist/surface/index.d.ts +20 -1
  70. package/dist/surface/index.js +2 -2
  71. package/dist/undo/index.d.ts +18 -7
  72. package/dist/undo/index.js +1 -1
  73. package/package.json +11 -11
  74. package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
  75. package/src/annotations/AnnotationOverlay.tsx +10 -1
  76. package/src/annotations/Annotations.less +2 -0
  77. package/src/annotations/Annotations.overlay.test.tsx +29 -0
  78. package/src/canvas/CanvasStack.test.tsx +35 -0
  79. package/src/canvas/CanvasStack.tsx +12 -1
  80. package/src/canvas/useLayerScheduler.ts +14 -0
  81. package/src/chrome/builtins.test.ts +14 -0
  82. package/src/chrome/builtins.tsx +17 -2
  83. package/src/chrome/regions/StatusRegion.tsx +20 -14
  84. package/src/chrome/regions/regions.test.tsx +28 -0
  85. package/src/chrome/types.ts +8 -0
  86. package/src/clock/clock.browser.test.less +5 -0
  87. package/src/clock/clock.browser.test.tsx +77 -0
  88. package/src/clock/clock.integration.test.tsx +95 -0
  89. package/src/clock/clockRegistry.test.ts +33 -0
  90. package/src/clock/clockRegistry.ts +46 -0
  91. package/src/clock/hooks.ts +44 -0
  92. package/src/clock/index.ts +2 -0
  93. package/src/clock/trialClock.test.ts +265 -0
  94. package/src/clock/trialClock.ts +245 -0
  95. package/src/clock/useClockLoop.ts +45 -0
  96. package/src/config/resolve.test.ts +12 -0
  97. package/src/config/resolve.ts +5 -0
  98. package/src/controls/ControlMatrix.tsx +56 -54
  99. package/src/index.ts +3 -2
  100. package/src/instrument/types.ts +21 -1
  101. package/src/lab/Lab.chrome.test.tsx +8 -1
  102. package/src/lab/Lab.presentation.test.tsx +113 -0
  103. package/src/lab/Lab.surface.test.tsx +20 -9
  104. package/src/lab/Lab.tsx +132 -257
  105. package/src/lab/LabLightbox.browser.test.less +17 -0
  106. package/src/lab/LabLightbox.browser.test.tsx +216 -0
  107. package/src/lab/LabShell.less +10 -3
  108. package/src/lab/LabSurface.tsx +129 -0
  109. package/src/lab/Workspace.stories.tsx +18 -0
  110. package/src/lab/Workspace.surface.test.tsx +4 -0
  111. package/src/lab/Workspace.test.tsx +37 -1
  112. package/src/lab/Workspace.tsx +34 -6
  113. package/src/lab/index.ts +2 -0
  114. package/src/lab/nebula.ts +30 -0
  115. package/src/lab/openLab.test.ts +81 -0
  116. package/src/lab/openLab.ts +119 -0
  117. package/src/lab/presentation.browser.test.less +5 -0
  118. package/src/lab/presentation.browser.test.tsx +119 -0
  119. package/src/lab/presentation.less +64 -0
  120. package/src/lab/presentation.tsx +47 -0
  121. package/src/lab/useFocusPick.ts +43 -0
  122. package/src/lightbox/Lightbox.browser.test.less +14 -0
  123. package/src/lightbox/Lightbox.browser.test.tsx +113 -0
  124. package/src/lightbox/Lightbox.less +128 -0
  125. package/src/lightbox/Lightbox.test.tsx +195 -0
  126. package/src/lightbox/Lightbox.tsx +205 -0
  127. package/src/lightbox/LightboxContext.ts +38 -0
  128. package/src/lightbox/LightboxLayers.tsx +40 -0
  129. package/src/lightbox/index.ts +8 -0
  130. package/src/lightbox/modal.ts +94 -0
  131. package/src/loupe/AGENTS.md +40 -2
  132. package/src/loupe/CanvasLoupe.tsx +5 -4
  133. package/src/loupe/DomLoupe.tsx +7 -5
  134. package/src/loupe/Loupe.less +15 -0
  135. package/src/loupe/LoupeBubble.tsx +35 -13
  136. package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
  137. package/src/loupe/SourceLoupe.tsx +60 -0
  138. package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
  139. package/src/loupe/TrialLoupe.tsx +126 -28
  140. package/src/loupe/canvasLens.ts +20 -17
  141. package/src/loupe/index.ts +26 -2
  142. package/src/loupe/sourceLens.test.ts +65 -0
  143. package/src/loupe/sourceLens.ts +97 -0
  144. package/src/loupe/types.test.ts +6 -0
  145. package/src/loupe/types.ts +109 -7
  146. package/src/loupe/useLoupe.test.tsx +188 -3
  147. package/src/loupe/useLoupe.ts +106 -11
  148. package/src/primitives/index.ts +2 -2
  149. package/src/specimen/Specimen.tsx +0 -7
  150. package/src/state/helpers.test.ts +4 -11
  151. package/src/state/helpers.ts +2 -8
  152. package/src/state/index.ts +0 -2
  153. package/src/state/openLabStore.test.ts +10 -8
  154. package/src/state/openLabStore.ts +1 -1
  155. package/src/state/store.test.ts +2 -2
  156. package/src/state/store.ts +15 -16
  157. package/src/state/types.ts +9 -9
  158. package/src/styles.less +2 -1
  159. package/src/surface/AGENTS.md +13 -0
  160. package/src/surface/index.ts +1 -0
  161. package/src/surface/useSurfaceTile.test.tsx +4 -0
  162. package/src/surface/useSurfaceTile.ts +17 -1
  163. package/src/surface/useTiledSurface.test.tsx +65 -0
  164. package/src/surface/useTiledSurface.ts +68 -1
  165. package/src/trial/Trial.stories.tsx +0 -1
  166. package/src/trial/Trial.test.tsx +40 -3
  167. package/src/trial/Trial.tsx +87 -43
  168. package/src/trial/TrialChrome.tsx +20 -2
  169. package/src/trial/trialOps.test.ts +1 -2
  170. package/src/trial/trialOps.ts +2 -3
  171. package/src/undo/stateOp.ts +21 -0
  172. package/src/undo/undoStack.ts +18 -6
  173. package/dist/chunk-3L4MLLQB.js.map +0 -1
  174. package/dist/chunk-7KAETQSS.js.map +0 -1
  175. package/dist/chunk-EESJUSYR.js.map +0 -1
  176. package/dist/chunk-J5HJJDLF.js.map +0 -1
  177. package/dist/chunk-NVKBL7ZE.js.map +0 -1
  178. package/dist/chunk-PMLZP7RT.js.map +0 -1
  179. package/dist/chunk-RL2LOLNI.js.map +0 -1
  180. package/dist/chunk-T6DLGEJB.js.map +0 -1
  181. package/dist/chunk-WIG6XHJ7.js.map +0 -1
  182. package/dist/chunk-WURZTJGQ.js.map +0 -1
  183. package/dist/chunk-XXACAPVU.js.map +0 -1
  184. package/dist/chunk-YDE6XATS.js.map +0 -1
  185. package/dist/chunk-YX75W6EK.js.map +0 -1
  186. package/src/primitives/StatusBar.less +0 -34
  187. package/src/primitives/StatusBar.stories.tsx +0 -24
  188. package/src/primitives/StatusBar.test.tsx +0 -25
  189. package/src/primitives/StatusBar.tsx +0 -28
@@ -3,9 +3,10 @@
3
3
  * `SurfaceContext` in production — so `useSurfaceOptional()` always answered
4
4
  * null and a tile registered from inside a trial reached nothing.
5
5
  *
6
- * The surface is anchored to `.lk-lab__body` rather than wrapped around the
7
- * workspace: `.lk-lab__body > .lk-workspace { min-width: 0 }` is a direct-child
8
- * selector, so an extra element between them silently drops that rule.
6
+ * The surface's layers sit in `.lk-lab__body` beside the workspace rather than
7
+ * wrapped around it: `.lk-lab__body > .lk-workspace { min-width: 0 }` is a
8
+ * direct-child selector, so an extra element between them silently drops that
9
+ * rule.
9
10
  */
10
11
  import { act } from '@testing-library/react';
11
12
  import { renderSettled } from '@weasel-js/react/testing/renderSettled';
@@ -84,19 +85,29 @@ describe('<Lab> surface provider', () => {
84
85
  expect(typeof (seen as unknown as SurfaceHandle).registerTile).toBe('function');
85
86
  });
86
87
 
87
- it('stacks an under and an over buffer inside the lab body', async () => {
88
+ it('stacks an under and an over buffer, each in its own layer of the lab body', async () => {
88
89
  const { container } = await renderSettled(
89
90
  <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />,
90
91
  );
91
- const canvases = container.querySelectorAll('.lk-lab__body > canvas.lk-lab__surface');
92
- expect(canvases).toHaveLength(2);
92
+ const layers = container.querySelectorAll('.lk-lab__body > .lk-lab__layer');
93
93
  // Source order, because the z-index that separates them is in a stylesheet
94
94
  // jsdom does not resolve — this is a proxy for the stacking, not proof of
95
95
  // it. The real check is a browser.
96
- expect([...canvases].map((c) => c.className)).toEqual([
97
- 'lk-lab__surface lk-lab__surface--under',
98
- 'lk-lab__surface lk-lab__surface--over',
96
+ expect([...layers].map((l) => l.className)).toEqual([
97
+ 'lk-lab__layer lk-lab__layer--under',
98
+ 'lk-lab__layer lk-lab__layer--over',
99
99
  ]);
100
+ for (const layer of layers)
101
+ expect(layer.querySelector('canvas.lk-lab__surface')).not.toBeNull();
102
+ });
103
+
104
+ it('measures tile rects against the over layer', async () => {
105
+ const { container } = await renderSettled(
106
+ <Lab instruments={[probeInstrument]} defaultInstrument="Probe" />,
107
+ );
108
+ expect((seen as SurfaceHandle).getContainer()).toBe(
109
+ container.querySelector('.lk-lab__layer--over'),
110
+ );
100
111
  });
101
112
 
102
113
  it('hands a tenant whichever of the two it asks for', async () => {
package/src/lab/Lab.tsx CHANGED
@@ -1,11 +1,9 @@
1
- import { type ColorList, colorAt, colorCount, resolveTheme, type Theme } from '@weasel-js/theme';
1
+ import { type ColorList, colorCount, type Theme } from '@weasel-js/theme';
2
2
  import { ThemeProvider, useResolvedColorMode } from '@weasel-js/theme/react';
3
3
  import {
4
4
  type CSSProperties,
5
5
  type ReactNode,
6
- useCallback,
7
6
  useEffect,
8
- useInsertionEffect,
9
7
  useLayoutEffect,
10
8
  useMemo,
11
9
  useRef,
@@ -24,21 +22,17 @@ import {
24
22
  } from '../chrome/LabChrome';
25
23
  import type { LabContribution } from '../chrome/labTypes';
26
24
  import type { TrialContribution } from '../chrome/types';
25
+ import { ClockRegistryContext, createClockRegistry } from '../clock/clockRegistry';
26
+ import { useClockLoop } from '../clock/useClockLoop';
27
27
  import type { ConfigRule, ControlRenderer } from '../config/types';
28
28
  import type { InstrumentList } from '../instrument/types';
29
29
  import { Split } from '../primitives/Split';
30
- import { defaultStorage, noneAdapter } from '../state/adapters';
31
30
  import { LabStoreContext } from '../state/context';
32
- import { type OpenedLabStore, openLabStore } from '../state/openLabStore';
33
31
  import { PersistenceContext } from '../state/Persistence';
34
- import { createRecordCache } from '../state/records';
35
- import { createLabStore, type LabStore } from '../state/store';
36
32
  import type { LabDensity, LabMode, StorageAdapter, TrialRecord } from '../state/types';
37
33
  import { useOpenOnce, useWarnIgnoredChange } from '../state/useOpenOnce';
38
34
  import { usePersistedState } from '../state/usePersistedState';
39
- import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
40
- import { useSurfaceCanvas, useSurfaceOptional } from '../surface/useSurfaceTile';
41
- import { type SurfaceFrame, useTiledSurface } from '../surface/useTiledSurface';
35
+ import { useSurfaceOptional } from '../surface/useSurfaceTile';
42
36
  import { interstellarTheme } from '../theme/interstellar';
43
37
  import type { TrialTool } from '../tools/types';
44
38
  import { Trial } from '../trial/Trial';
@@ -55,9 +49,20 @@ import { LabContext, type LabContextValue } from './LabContext';
55
49
  import { LabHeader, LabThemeSwitcher } from './LabHeader';
56
50
  import { LabPalette } from './LabPalette';
57
51
  import { LabShell } from './LabShell';
52
+ import { LabSurface } from './LabSurface';
58
53
  import type { LabPage } from './LabSwitcher';
59
54
  import { LabZoom } from './LabZoom';
55
+ import { buildNebula } from './nebula';
56
+ import {
57
+ type OpenedLab,
58
+ openStoredLab,
59
+ openUnstoredLab,
60
+ type PresentationSeed,
61
+ presentStorageKey,
62
+ } from './openLab';
60
63
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
64
+ import { hasPresentParam, PresentationContext } from './presentation';
65
+ import { useFocusPick } from './useFocusPick';
61
66
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
62
67
 
63
68
  interface LabBaseProps {
@@ -104,6 +109,10 @@ interface LabBaseProps {
104
109
  labChrome?: readonly LabContribution[];
105
110
  /** Built-in contribution ids to drop. Throws on an id that is not there. */
106
111
  suppress?: readonly string[];
112
+ /** Whether a double-click on a trial also opens it in its lightbox, beside
113
+ * the title bar's expand toggle every trial has. Off by default; a
114
+ * function decides per trial, by its record. */
115
+ expandOnDoubleClick?: boolean | ((trial: TrialRecord) => boolean);
107
116
  /** Offer the header's add-trial control. Default `true`; a lab that opens
108
117
  * trials some other way passes `false`. */
109
118
  addTrial?: boolean;
@@ -121,6 +130,15 @@ interface LabBaseProps {
121
130
  * Keys are config paths (checked first) or leaf kinds. Memoize or hoist. */
122
131
  controls?: Record<string, ControlRenderer>;
123
132
  children?: ReactNode;
133
+ /** Start presenting: one trial, opened on `seed`, without the lab's chrome or
134
+ * its own. `?present` in the page's URL does the same. Read once, at mount.
135
+ * A stored lab presents from its own records, under `storageKey` +
136
+ * `':present'`, so an embed never shows or changes the full lab's trials. */
137
+ present?: boolean;
138
+ /** The trial a lab that starts presenting opens on. A store that already
139
+ * holds a trial opened on this same seed keeps it; a changed seed replaces
140
+ * it. Ignored unless the lab starts presenting. */
141
+ seed?: PresentationSeed;
124
142
  }
125
143
 
126
144
  /** Props for `<Lab>`. With a `storageKey` the lab persists — to IndexedDB
@@ -132,61 +150,6 @@ export type LabProps = LabBaseProps &
132
150
  | { storageKey: string; storage?: StorageAdapter }
133
151
  );
134
152
 
135
- interface OpenedLab extends OpenedLabStore {
136
- marks: ReadonlyMap<string, unknown>;
137
- }
138
-
139
- function seedDefaultTrial(
140
- store: LabStore,
141
- instruments: InstrumentList,
142
- defaultInstrument: string,
143
- ): void {
144
- if (store.getState().trials.length > 0) return;
145
- const record = addTrialOp([], instruments, defaultInstrument)[0];
146
- if (!record) return;
147
- const { undoStack: _undoStack, ...rest } = record;
148
- store.getState().addTrial(rest);
149
- }
150
-
151
- /** A lab with nothing to load renders at once. Its records hold
152
- * `usePersistedState` values for the session and write nothing. */
153
- function openUnstoredLab({ instruments, defaultInstrument, mode }: LabProps): OpenedLab {
154
- const store = createLabStore({ initialMode: mode ?? 'auto', instruments });
155
- seedDefaultTrial(store, instruments, defaultInstrument);
156
- const records = createRecordCache({ storage: noneAdapter, prefix: '', writable: false });
157
- return { store, records, close: () => records.close(), marks: new Map() };
158
- }
159
-
160
- async function openStoredLab(
161
- { instruments, defaultInstrument, mode }: LabProps,
162
- storageKey: string,
163
- storage: StorageAdapter | undefined,
164
- ): Promise<OpenedLab> {
165
- // The store reads the instruments' serializers and defaults while it is being
166
- // built, so they go in with it rather than being pushed onto it afterwards.
167
- const opened = await openLabStore({
168
- storageKey,
169
- storage: storage ?? (await defaultStorage()),
170
- initialMode: mode ?? 'auto',
171
- instruments,
172
- });
173
- seedDefaultTrial(opened.store, instruments, defaultInstrument);
174
- const marks = new Map<string, unknown>();
175
- await Promise.all(
176
- opened.store.getState().trials.map(async (trial) => {
177
- const kept = instruments.find((i) => i.name === trial.instrumentName)?.annotations?.storage;
178
- if (!kept) return;
179
- try {
180
- marks.set(trial.id, await kept.load());
181
- } catch (error) {
182
- console.warn(`[labkit] could not load the marks of trial "${trial.id}"`, error);
183
- marks.set(trial.id, null);
184
- }
185
- }),
186
- );
187
- return { ...opened, marks };
188
- }
189
-
190
153
  function LabFallback({
191
154
  title,
192
155
  mode,
@@ -214,35 +177,6 @@ function LabFallback({
214
177
  );
215
178
  }
216
179
 
217
- // Fixed spread of nebula blob positions / sizes / fall-off stops. Colors
218
- // supplied via the `nebula` prop are slotted into these slots in order;
219
- // callers passing more than 5 wrap around (intentional — keeps the look
220
- // readable and bounded).
221
- const NEBULA_SLOTS = [
222
- { cx: '18%', cy: '30%', sx: '60%', sy: '80%', stop: '70%' },
223
- { cx: '78%', cy: '70%', sx: '55%', sy: '90%', stop: '65%' },
224
- { cx: '50%', cy: '50%', sx: '50%', sy: '70%', stop: '75%' },
225
- { cx: '12%', cy: '82%', sx: '50%', sy: '70%', stop: '70%' },
226
- { cx: '85%', cy: '18%', sx: '55%', sy: '80%', stop: '70%' },
227
- ] as const;
228
-
229
- function buildNebula(
230
- list: ColorList,
231
- theme: Theme,
232
- mode: 'light' | 'dark',
233
- density: string,
234
- ): string {
235
- const ctx = { theme, resolved: resolveTheme(theme, { mode, density }) };
236
- const n = colorCount(list, ctx) ?? NEBULA_SLOTS.length;
237
- const colors = Array.from({ length: n }, (_, i) => colorAt(list, i, ctx));
238
- const blobs = colors.map((c, i) => {
239
- const p = NEBULA_SLOTS[i % NEBULA_SLOTS.length];
240
- return `radial-gradient(ellipse ${p.sx} ${p.sy} at ${p.cx} ${p.cy}, color-mix(in srgb, ${c} 22%, transparent), transparent ${p.stop})`;
241
- });
242
- blobs.push('radial-gradient(ellipse at center, #0a0a18 0%, #02020a 100%)');
243
- return blobs.join(', ');
244
- }
245
-
246
180
  /** The strip holding the sidebar beside the tool rail and workspace. Its own component
247
181
  * because the width is a persisted value, and `LabRuntime` renders the
248
182
  * persistence provider it reads from. */
@@ -310,13 +244,22 @@ export function Lab(props: LabProps) {
310
244
  if (process.env.NODE_ENV !== 'production' && props.instruments.length === 0) {
311
245
  throw new Error('[labkit] <Lab> requires a non-empty `instruments` array');
312
246
  }
313
- const { storageKey, storage } = props;
314
- useWarnIgnoredChange('<Lab>', { storageKey, storage });
315
- const opened = useOpenOnce<OpenedLab>(() =>
316
- storageKey === undefined ? openUnstoredLab(props) : openStoredLab(props, storageKey, storage),
317
- );
247
+ const { storageKey, storage, present } = props;
248
+ useWarnIgnoredChange('<Lab>', { storageKey, storage, present });
249
+ const [startsPresenting] = useState(() => present === true || hasPresentParam());
250
+ if (process.env.NODE_ENV !== 'production' && props.seed && !startsPresenting) {
251
+ warnSeedIgnored();
252
+ }
253
+ const opened = useOpenOnce<OpenedLab>(() => {
254
+ const seed = startsPresenting ? (props.seed ?? {}) : undefined;
255
+ if (storageKey === undefined) return openUnstoredLab(props, seed);
256
+ const key = startsPresenting ? presentStorageKey(storageKey) : storageKey;
257
+ return openStoredLab(props, key, storage, seed);
258
+ });
318
259
  if (!opened) {
319
260
  if (props.fallback !== undefined) return props.fallback;
261
+ // An embed shows nothing until its trial is ready, never the lab's shell.
262
+ if (startsPresenting) return null;
320
263
  return (
321
264
  <LabFallback
322
265
  title={props.title}
@@ -328,7 +271,14 @@ export function Lab(props: LabProps) {
328
271
  />
329
272
  );
330
273
  }
331
- return <LabRuntime {...props} opened={opened} />;
274
+ return <LabRuntime {...props} opened={opened} startsPresenting={startsPresenting} />;
275
+ }
276
+
277
+ let seedWarned = false;
278
+ function warnSeedIgnored(): void {
279
+ if (seedWarned) return;
280
+ seedWarned = true;
281
+ console.warn('[labkit] <Lab> reads `seed` only when it starts presenting; it is ignored here');
332
282
  }
333
283
 
334
284
  function LabRuntime({
@@ -344,6 +294,7 @@ function LabRuntime({
344
294
  chrome,
345
295
  labChrome,
346
296
  suppress,
297
+ expandOnDoubleClick = false,
347
298
  addTrial,
348
299
  zoom = true,
349
300
  tools,
@@ -351,7 +302,8 @@ function LabRuntime({
351
302
  controls,
352
303
  children,
353
304
  opened,
354
- }: LabProps & { opened: OpenedLab }) {
305
+ startsPresenting,
306
+ }: LabProps & { opened: OpenedLab; startsPresenting: boolean }) {
355
307
  const { store } = opened;
356
308
 
357
309
  // The store opened against the list <Lab> mounted with; a later list reaches
@@ -362,10 +314,26 @@ function LabRuntime({
362
314
 
363
315
  const trials = useStore(store, (s) => s.trials);
364
316
  const [cameras] = useState(createCameraRegistry);
317
+ const [clocks] = useState(createClockRegistry);
318
+ useClockLoop(clocks);
365
319
  const [focusPick, setFocusPick] = useState<string | null>(null);
366
320
  const focusedTrialId = trials.some((t) => t.id === focusPick)
367
321
  ? focusPick
368
322
  : (trials[0]?.id ?? null);
323
+ // `mount` when the lab started presenting, `enter` when asked to since.
324
+ const [presentedBy, setPresentedBy] = useState<'mount' | 'enter' | null>(
325
+ startsPresenting ? 'mount' : null,
326
+ );
327
+ const presenting = presentedBy !== null;
328
+ const presentation = useMemo(
329
+ () => ({
330
+ active: presenting,
331
+ enter: () => setPresentedBy((by) => by ?? 'enter'),
332
+ exit: () => setPresentedBy(null),
333
+ trialId: presenting ? focusedTrialId : null,
334
+ }),
335
+ [presenting, focusedTrialId],
336
+ );
369
337
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
370
338
  const modeValue = useStore(store, (s) => s.mode);
371
339
  const layout = useStore(store, (s) => s.layout);
@@ -377,102 +345,22 @@ function LabRuntime({
377
345
  const panelHostsRef = useRef<ReturnType<typeof createPanelHostRegistry> | null>(null);
378
346
  if (panelHostsRef.current === null) panelHostsRef.current = createPanelHostRegistry();
379
347
 
380
- // One shared drawing surface for the whole lab, anchored to the body — tile
381
- // rects compose against it, and both buffers compose against the same rects.
382
- // `Workspace` invalidates rects when the grid moves something a
383
- // ResizeObserver cannot see. A host that already owns a surface keeps it: a
384
- // lab embedded in a larger shared-surface app must not open a second GL
385
- // tenancy, and mounts no buffer of its own.
386
- const outerSurface = useSurfaceOptional();
387
- const outerOver = useSurfaceCanvas('over');
388
- const outerUnder = useSurfaceCanvas('under');
389
- const [ownOver, setOwnOver] = useState<HTMLCanvasElement | null>(null);
390
- const [ownUnder, setOwnUnder] = useState<HTMLCanvasElement | null>(null);
391
- const ownOverRef = useRef<HTMLCanvasElement | null>(null);
392
- const ownUnderRef = useRef<HTMLCanvasElement | null>(null);
393
- const bufferRef = useRef({ w: 0, h: 0 });
394
- const surfaceRef = useRef<ReturnType<typeof useTiledSurface> | null>(null);
395
-
396
- // Sizing the buffer clears all of it, so every tile has to repaint — not
397
- // only the one whose move triggered the measurement. Both buffers are sized
398
- // together off one comparison: they are the same box, so a tenant of either
399
- // is looking at the same rects, and one invalidateAll covers both.
400
- const onFrame = useCallback((frame: SurfaceFrame) => {
401
- const canvases = [ownUnderRef.current, ownOverRef.current].filter((c) => c !== null);
402
- if (canvases.length === 0) return;
403
- const w = Math.round(frame.size.width * frame.dpr);
404
- const h = Math.round(frame.size.height * frame.dpr);
405
- if (bufferRef.current.w !== w || bufferRef.current.h !== h) {
406
- bufferRef.current = { w, h };
407
- for (const c of canvases) {
408
- c.width = w;
409
- c.height = h;
410
- c.style.width = `${frame.size.width}px`;
411
- c.style.height = `${frame.size.height}px`;
412
- }
413
- surfaceRef.current?.invalidateAll();
414
- return;
415
- }
416
- // A same-size re-tile does not go through here: assigning `width` its own
417
- // value resizes nothing, so it clears nothing. The tenants clear, through
418
- // `registerClear`.
419
- }, []);
420
-
421
- const ownSurface = useTiledSurface({ onFrame });
422
- // Not `useLatest`: `onFrame` reads it, and is needed before the surface exists.
423
- useInsertionEffect(() => {
424
- surfaceRef.current = ownSurface;
425
- }, [ownSurface]);
426
348
  const [labBody, setLabBody] = useState<HTMLDivElement | null>(null);
427
- const attachOwnSurface = ownSurface.containerRef;
428
- const labBodyRef = useCallback(
429
- (el: HTMLDivElement | null) => {
430
- setLabBody(el);
431
- if (!outerSurface) attachOwnSurface(el);
432
- },
433
- [outerSurface, attachOwnSurface],
434
- );
435
349
  useLabFitWarning(labBody);
436
350
 
351
+ useFocusPick(labBody, store, setFocusPick);
352
+
353
+ // Only a lab asked to present has a workspace to go back to.
437
354
  useEffect(() => {
438
- if (!labBody) return;
439
- const pick = (node: unknown): void => {
440
- const owned = new Set(store.getState().trials.map((t) => t.id));
441
- const nearest = (from: Element | null | undefined) =>
442
- from?.closest<HTMLElement>('.lk-trial[data-trial-id]') ?? null;
443
- let trial =
444
- typeof (node as Element | null)?.closest === 'function' ? nearest(node as Element) : null;
445
- // A trial can hold a lab of its own, whose trials this lab does not own.
446
- while (trial && !owned.has(trial.dataset.trialId ?? '')) trial = nearest(trial.parentElement);
447
- if (trial?.dataset.trialId) setFocusPick(trial.dataset.trialId);
448
- };
449
- const onInput = (event: Event): void => pick(event.target);
450
- // Focus moving into a frame fires nothing in this document: the window
451
- // blurs, and the frame is the active element once it has.
452
- let pending: ReturnType<typeof setTimeout> | undefined;
355
+ if (presentedBy !== 'enter' || !labBody) return;
453
356
  const doc = labBody.ownerDocument;
454
- const win = doc.defaultView;
455
- const onBlur = (): void => {
456
- clearTimeout(pending);
457
- pending = setTimeout(() => pick(doc.activeElement), 0);
458
- };
459
- labBody.addEventListener('pointerdown', onInput, true);
460
- labBody.addEventListener('focusin', onInput, true);
461
- win?.addEventListener('blur', onBlur);
462
- return () => {
463
- clearTimeout(pending);
464
- labBody.removeEventListener('pointerdown', onInput, true);
465
- labBody.removeEventListener('focusin', onInput, true);
466
- win?.removeEventListener('blur', onBlur);
357
+ const onKeyDown = (event: KeyboardEvent): void => {
358
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
359
+ setPresentedBy(null);
467
360
  };
468
- }, [labBody, store]);
469
- const surface = outerSurface ?? ownSurface;
470
- const surfaceCanvases = useMemo(
471
- () =>
472
- outerSurface ? { over: outerOver, under: outerUnder } : { over: ownOver, under: ownUnder },
473
- [outerSurface, outerOver, outerUnder, ownOver, ownUnder],
474
- );
475
-
361
+ doc.addEventListener('keydown', onKeyDown);
362
+ return () => doc.removeEventListener('keydown', onKeyDown);
363
+ }, [presentedBy, labBody]);
476
364
  const workspacePanels = useMemo<PanelDescriptor[]>(
477
365
  () =>
478
366
  Object.entries(undockedPanels).map(([key, panel]) => ({
@@ -558,6 +446,7 @@ function LabRuntime({
558
446
  : w,
559
447
  ),
560
448
  }));
449
+ clocks.get(id)?.reset();
561
450
  },
562
451
  savedSnapshots,
563
452
  saveSnapshot: (trialId, name) => {
@@ -578,6 +467,7 @@ function LabRuntime({
578
467
  controls,
579
468
  };
580
469
  }, [
470
+ clocks,
581
471
  instruments,
582
472
  trials,
583
473
  focusedTrialId,
@@ -611,6 +501,14 @@ function LabRuntime({
611
501
  onReorder={(ids) => contextValue.reorderTrials(ids)}
612
502
  layout={layout as TrialLayout}
613
503
  onLayoutChange={(next) => store.getState().setLayout(next)}
504
+ expandOnDoubleClick={
505
+ typeof expandOnDoubleClick === 'function'
506
+ ? (id) => {
507
+ const trial = trials.find((t) => t.id === id);
508
+ return trial ? expandOnDoubleClick(trial) : false;
509
+ }
510
+ : expandOnDoubleClick
511
+ }
614
512
  >
615
513
  {trials.map((w) => (
616
514
  <Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
@@ -623,65 +521,42 @@ function LabRuntime({
623
521
  <PersistenceContext.Provider value={opened.records}>
624
522
  <AnnotationPreloadContext.Provider value={opened.marks}>
625
523
  <LabContext.Provider value={contextValue}>
626
- <CameraRegistryContext.Provider value={cameras}>
627
- <ThemeProvider
628
- theme={theme}
629
- selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
630
- className="lk-lab"
631
- style={backdropStyle}
632
- >
633
- <LabShell
634
- title={title ?? 'Labkit'}
635
- mode={modeValue}
636
- {...(pages ? { pages } : {})}
637
- {...(path !== undefined ? { path } : {})}
638
- footer={
639
- hasFooterChrome ? (
640
- <>
641
- {footer}
642
- <LabFooterRegion contributions={labChromeAll} />
643
- </>
644
- ) : (
645
- footer
646
- )
647
- }
648
- header={
649
- <>
650
- <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
651
- {zoom ? <LabZoom /> : null}
652
- {children}
653
- <LabHeaderRegion contributions={labChromeAll} />
654
- <LabThemeSwitcher />
655
- </>
656
- }
657
- >
658
- <PanelHostContext.Provider value={panelHostsRef.current}>
659
- <SurfaceContext.Provider value={surface}>
660
- <SurfaceCanvasContext.Provider value={surfaceCanvases}>
661
- <div className="lk-lab__body" ref={labBodyRef}>
662
- {outerSurface ? null : (
663
- // Two buffers stacked around the trials, both inert —
664
- // each tile takes input from its own box. A tile's marks
665
- // annotate the instrument's DOM from over it; an opaque
666
- // renderer sits under it, so the pane can still hold a
667
- // label. The under one is first so it paints first.
668
- <>
669
- <canvas
670
- className="lk-lab__surface lk-lab__surface--under"
671
- ref={(el) => {
672
- ownUnderRef.current = el;
673
- setOwnUnder(el);
674
- }}
675
- />
676
- <canvas
677
- className="lk-lab__surface lk-lab__surface--over"
678
- ref={(el) => {
679
- ownOverRef.current = el;
680
- setOwnOver(el);
681
- }}
682
- />
683
- </>
684
- )}
524
+ <PresentationContext.Provider value={presentation}>
525
+ <CameraRegistryContext.Provider value={cameras}>
526
+ <ClockRegistryContext.Provider value={clocks}>
527
+ <ThemeProvider
528
+ theme={theme}
529
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
530
+ className={presenting ? 'lk-lab lk-lab--present' : 'lk-lab'}
531
+ style={backdropStyle}
532
+ >
533
+ <LabShell
534
+ title={title ?? 'Labkit'}
535
+ mode={modeValue}
536
+ {...(pages ? { pages } : {})}
537
+ {...(path !== undefined ? { path } : {})}
538
+ footer={
539
+ hasFooterChrome ? (
540
+ <>
541
+ {footer}
542
+ <LabFooterRegion contributions={labChromeAll} />
543
+ </>
544
+ ) : (
545
+ footer
546
+ )
547
+ }
548
+ header={
549
+ <>
550
+ <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
551
+ {zoom && !presenting ? <LabZoom /> : null}
552
+ {children}
553
+ <LabHeaderRegion contributions={labChromeAll} />
554
+ <LabThemeSwitcher />
555
+ </>
556
+ }
557
+ >
558
+ <PanelHostContext.Provider value={panelHostsRef.current}>
559
+ <LabSurface bodyRef={setLabBody}>
685
560
  {hasPaneChrome ? (
686
561
  <LabPanes contributions={labChromeAll}>
687
562
  <LabPalette contributions={labChromeAll} />
@@ -693,13 +568,13 @@ function LabRuntime({
693
568
  {workspace}
694
569
  </>
695
570
  )}
696
- </div>
697
- </SurfaceCanvasContext.Provider>
698
- </SurfaceContext.Provider>
699
- </PanelHostContext.Provider>
700
- </LabShell>
701
- </ThemeProvider>
702
- </CameraRegistryContext.Provider>
571
+ </LabSurface>
572
+ </PanelHostContext.Provider>
573
+ </LabShell>
574
+ </ThemeProvider>
575
+ </ClockRegistryContext.Provider>
576
+ </CameraRegistryContext.Provider>
577
+ </PresentationContext.Provider>
703
578
  </LabContext.Provider>
704
579
  </AnnotationPreloadContext.Provider>
705
580
  </PersistenceContext.Provider>
@@ -0,0 +1,17 @@
1
+ .lk-lightbox-lab-host {
2
+ width: 900px;
3
+ height: 560px;
4
+ }
5
+
6
+ // The target fills its pane in one flat color, so a mark is the only thing
7
+ // that can change a pixel inside it.
8
+ .lk-lightbox-lab-pane {
9
+ width: 100%;
10
+ height: 60%;
11
+ background: rgb(0 0 255);
12
+ }
13
+
14
+ .lk-lightbox-lab-solid {
15
+ width: 100%;
16
+ height: 30%;
17
+ }