@weasel-js/labkit 1.5.0 → 1.5.2

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 (206) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CMMo180l.d.ts} +1 -1
  3. package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
  4. package/dist/_dts/Select.d-ZTiwzYyT.d.ts +479 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4hFJMiN.d.ts} +54 -6
  6. package/dist/_dts/{index-retnAut7.d.ts → index-8LiKe3A-.d.ts} +77 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-DlKolEIZ.d.ts} +20 -8
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-Db-3NNk1.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-SSTZe410.d.ts} +4 -47
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-pnAE-UBD.d.ts} +1 -1
  11. package/dist/canvas/index.d.ts +5 -5
  12. package/dist/canvas/index.js +1 -1
  13. package/dist/chrome/index.d.ts +4 -4
  14. package/dist/chrome/index.js +7 -7
  15. package/dist/chunk-6BUQ77ZV.js +18 -0
  16. package/dist/chunk-6BUQ77ZV.js.map +1 -0
  17. package/dist/{chunk-KJALCDWE.js → chunk-753Y6HE6.js} +9 -9
  18. package/dist/chunk-753Y6HE6.js.map +1 -0
  19. package/dist/{chunk-6ZDGOZQV.js → chunk-A2ACAGW6.js} +72 -20
  20. package/dist/chunk-A2ACAGW6.js.map +1 -0
  21. package/dist/{chunk-NS64DXMN.js → chunk-CKUKF72R.js} +44 -21
  22. package/dist/chunk-CKUKF72R.js.map +1 -0
  23. package/dist/{chunk-MUKOW3TC.js → chunk-D35PFTS7.js} +4 -4
  24. package/dist/{chunk-MUKOW3TC.js.map → chunk-D35PFTS7.js.map} +1 -1
  25. package/dist/{chunk-UU3NJO6N.js → chunk-EMXAIRUJ.js} +3 -3
  26. package/dist/{chunk-UU3NJO6N.js.map → chunk-EMXAIRUJ.js.map} +1 -1
  27. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  28. package/dist/chunk-ERFMC6WU.js.map +1 -0
  29. package/dist/chunk-FKLT3ITC.js +590 -0
  30. package/dist/chunk-FKLT3ITC.js.map +1 -0
  31. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  32. package/dist/chunk-IJJUK4FU.js.map +1 -0
  33. package/dist/{chunk-EKIICY6X.js → chunk-KJCUERQF.js} +37 -25
  34. package/dist/chunk-KJCUERQF.js.map +1 -0
  35. package/dist/chunk-LASCBWW7.js +13456 -0
  36. package/dist/chunk-LASCBWW7.js.map +1 -0
  37. package/dist/{chunk-67SJLMC7.js → chunk-LRI3MBGR.js} +45 -12
  38. package/dist/chunk-LRI3MBGR.js.map +1 -0
  39. package/dist/chunk-REOBGYMC.js +150 -0
  40. package/dist/chunk-REOBGYMC.js.map +1 -0
  41. package/dist/{chunk-PGETSEDK.js → chunk-TT5C5S6R.js} +11 -90
  42. package/dist/chunk-TT5C5S6R.js.map +1 -0
  43. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  44. package/dist/chunk-Z76KEZAX.js.map +1 -0
  45. package/dist/config/index.d.ts +62 -19
  46. package/dist/config/index.js +6 -4
  47. package/dist/controls/index.d.ts +3 -3
  48. package/dist/controls/index.js +4 -4
  49. package/dist/dragdrop/index.d.ts +3 -3
  50. package/dist/index.d.ts +230 -62
  51. package/dist/index.js +1367 -1698
  52. package/dist/index.js.map +1 -1
  53. package/dist/layers/index.d.ts +4 -4
  54. package/dist/layers/index.js +2 -2
  55. package/dist/loupe/index.d.ts +67 -16
  56. package/dist/loupe/index.js +1 -1
  57. package/dist/passthrough/weasel-ui.d.ts +57 -230
  58. package/dist/passthrough/weasel-ui.js +1 -1
  59. package/dist/primitives/index.d.ts +3 -1
  60. package/dist/primitives/index.js +3 -3
  61. package/dist/state/index.d.ts +5 -5
  62. package/dist/state/index.js +5 -3
  63. package/dist/state/index.js.map +1 -1
  64. package/dist/styles.css +266 -57
  65. package/dist/surface/index.js +1 -1
  66. package/dist/ui/layers/index.js +1 -1
  67. package/dist/undo/index.d.ts +3 -3
  68. package/package.json +9 -9
  69. package/src/annotations/AnnotationOverlay.tsx +3 -0
  70. package/src/annotations/Annotations.less +4 -0
  71. package/src/annotations/Annotations.overlay.test.tsx +20 -0
  72. package/src/annotations/svgNodes.ts +1 -0
  73. package/src/annotations/toolMap.test.ts +9 -5
  74. package/src/annotations/toolMap.ts +3 -0
  75. package/src/canvas/CanvasStack.stories.tsx +1 -1
  76. package/src/canvas/index.ts +0 -9
  77. package/src/canvas/usePanZoom.test.ts +28 -0
  78. package/src/canvas/usePanZoom.ts +11 -4
  79. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  80. package/src/chrome/LabChrome.tsx +21 -5
  81. package/src/chrome/builtins.test.ts +1 -0
  82. package/src/chrome/builtins.tsx +1 -0
  83. package/src/chrome/index.ts +1 -0
  84. package/src/chrome/labTypes.ts +3 -3
  85. package/src/chrome/regions/PaletteRegion.test.tsx +18 -0
  86. package/src/chrome/regions/PaletteRegion.tsx +6 -10
  87. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  88. package/src/chrome/regions/TitleBarRegion.tsx +1 -1
  89. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  90. package/src/chrome/types.ts +11 -2
  91. package/src/config/auto.test.ts +12 -0
  92. package/src/config/auto.ts +15 -0
  93. package/src/config/autoConfig.test.ts +107 -0
  94. package/src/config/autoConfig.ts +153 -0
  95. package/src/config/builder.test.ts +38 -0
  96. package/src/config/builder.ts +55 -11
  97. package/src/config/index.ts +3 -0
  98. package/src/config/path.ts +7 -2
  99. package/src/config/resolve.test.ts +25 -0
  100. package/src/config/resolve.ts +17 -1
  101. package/src/config/types.ts +32 -2
  102. package/src/config/useResolvedConfig.ts +26 -0
  103. package/src/controls/AutoControls.stories.tsx +88 -0
  104. package/src/controls/ControlPanel.less +23 -0
  105. package/src/controls/ControlPanel.stories.tsx +83 -1
  106. package/src/controls/ControlPanel.test.tsx +457 -4
  107. package/src/controls/ControlPanel.tsx +534 -73
  108. package/src/index.test.ts +0 -2
  109. package/src/index.ts +33 -11
  110. package/src/instrument/serializers.ts +29 -1
  111. package/src/lab/Lab.chrome.test.tsx +16 -0
  112. package/src/lab/Lab.stories.tsx +1 -1
  113. package/src/lab/Lab.surface.test.tsx +7 -2
  114. package/src/lab/Lab.test.tsx +79 -1
  115. package/src/lab/Lab.tsx +124 -16
  116. package/src/lab/LabContext.ts +8 -0
  117. package/src/lab/LabFit.stories.tsx +4 -4
  118. package/src/lab/LabFullChrome.stories.tsx +1 -1
  119. package/src/lab/LabHeader.tsx +0 -1
  120. package/src/lab/LabRoot.test.tsx +56 -0
  121. package/src/lab/LabRoot.tsx +47 -0
  122. package/src/lab/LabShell.less +24 -3
  123. package/src/lab/LabShell.stories.tsx +50 -1
  124. package/src/lab/LabShell.tsx +18 -27
  125. package/src/lab/LabSwitcher.less +10 -10
  126. package/src/lab/LabSwitcher.test.tsx +14 -9
  127. package/src/lab/LabSwitcher.tsx +8 -4
  128. package/src/lab/Workspace.less +1 -1
  129. package/src/lab/Workspace.stories.tsx +1 -1
  130. package/src/lab/Workspace.surface.test.tsx +1 -1
  131. package/src/lab/index.ts +2 -0
  132. package/src/layers/LayerList.less +1 -1
  133. package/src/layers/LayerList.stories.tsx +2 -2
  134. package/src/loupe/AGENTS.md +16 -4
  135. package/src/loupe/LoupeGestures.tsx +67 -0
  136. package/src/loupe/TrialLoupe.tsx +43 -28
  137. package/src/loupe/index.ts +4 -0
  138. package/src/loupe/loupeActions.ts +98 -0
  139. package/src/loupe/useLoupe.test.tsx +59 -2
  140. package/src/loupe/useLoupe.ts +28 -48
  141. package/src/passthrough/weasel-ui.ts +3 -3
  142. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  143. package/src/primitives/FpsMeter.less +6 -1
  144. package/src/primitives/FpsMeter.stories.tsx +1 -1
  145. package/src/primitives/FpsMeter.test.tsx +12 -4
  146. package/src/primitives/FpsMeter.tsx +6 -1
  147. package/src/primitives/JobProgress.stories.tsx +2 -2
  148. package/src/primitives/Legend.stories.tsx +1 -1
  149. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  150. package/src/primitives/Sidebar.less +7 -0
  151. package/src/primitives/Sidebar.stories.tsx +1 -1
  152. package/src/primitives/Split.test.tsx +25 -0
  153. package/src/primitives/Split.tsx +36 -20
  154. package/src/primitives/StatusBar.less +6 -0
  155. package/src/primitives/StatusBar.stories.tsx +1 -1
  156. package/src/primitives/Toolbar.stories.tsx +1 -1
  157. package/src/specimen/Specimen.less +95 -0
  158. package/src/specimen/Specimen.stories.tsx +13 -0
  159. package/src/specimen/Specimen.test.tsx +13 -0
  160. package/src/specimen/Specimen.tsx +888 -0
  161. package/src/state/Persistence.stories.tsx +4 -4
  162. package/src/state/store.test.ts +65 -0
  163. package/src/state/store.ts +32 -8
  164. package/src/state/types.ts +31 -2
  165. package/src/state/useTrialState.test.tsx +85 -0
  166. package/src/state/useTrialState.ts +9 -1
  167. package/src/state/view.test.ts +4 -4
  168. package/src/styles.less +1 -0
  169. package/src/surface/index.ts +1 -1
  170. package/src/surface/useSurfaceTile.test.tsx +1 -1
  171. package/src/surface/useTiledSurface.test.tsx +65 -10
  172. package/src/surface/useTiledSurface.ts +23 -11
  173. package/src/theme/Interstellar.stories.tsx +2 -2
  174. package/src/theme/interstellar.test.ts +16 -5
  175. package/src/theme/interstellar.theme.json +180 -0
  176. package/src/theme/interstellar.ts +8 -5
  177. package/src/tools/labTool.ts +3 -3
  178. package/src/trial/Trial.annotations.test.tsx +13 -4
  179. package/src/trial/Trial.canvas.test.tsx +36 -0
  180. package/src/trial/Trial.config.test.tsx +75 -2
  181. package/src/trial/Trial.less +4 -0
  182. package/src/trial/Trial.stories.tsx +4 -1
  183. package/src/trial/Trial.test.tsx +3 -0
  184. package/src/trial/Trial.tsx +21 -14
  185. package/src/trial/TrialChrome.tsx +17 -3
  186. package/src/trial/trialOps.test.ts +101 -1
  187. package/src/trial/trialOps.ts +62 -4
  188. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  189. package/dist/chunk-67SJLMC7.js.map +0 -1
  190. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  191. package/dist/chunk-E44SU6XS.js +0 -50
  192. package/dist/chunk-E44SU6XS.js.map +0 -1
  193. package/dist/chunk-EKIICY6X.js.map +0 -1
  194. package/dist/chunk-KJALCDWE.js.map +0 -1
  195. package/dist/chunk-NDRYLVOW.js.map +0 -1
  196. package/dist/chunk-NS64DXMN.js.map +0 -1
  197. package/dist/chunk-PGETSEDK.js.map +0 -1
  198. package/dist/chunk-QLSV2N3G.js.map +0 -1
  199. package/dist/chunk-S2SHZRD7.js +0 -7681
  200. package/dist/chunk-S2SHZRD7.js.map +0 -1
  201. package/dist/chunk-TQBBYTAF.js +0 -280
  202. package/dist/chunk-TQBBYTAF.js.map +0 -1
  203. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  204. package/src/canvas/useOrbit.test.ts +0 -167
  205. package/src/canvas/useOrbit.ts +0 -156
  206. package/src/theme/interstellar.tokens.json +0 -61
package/src/index.test.ts CHANGED
@@ -72,8 +72,6 @@ describe('surface, job and orbit entry points', () => {
72
72
  expect(typeof kit.annotationsFromJSON).toBe('function');
73
73
  expect(typeof kit.seenFrom).toBe('function');
74
74
  expect(typeof kit.fracToWorld).toBe('function');
75
- expect(typeof kit.useOrbit).toBe('function');
76
- expect(typeof kit.orbitAfterDrag).toBe('function');
77
75
  expect(typeof kit.usePanZoom).toBe('function');
78
76
  expect(typeof kit.useJob).toBe('function');
79
77
  expect(typeof kit.as2DView).toBe('function');
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
- // The property-panel family, LayerStack, ToggleBar and Button live in
1
+ // The property-panel family, LayerStack, Input, Select, TokenPanel, ToggleBar and Button live in
2
2
  // `@weasel-js/ui`; labkit re-exports them so chrome built on labkit needs no
3
3
  // direct ui dependency. Named, not `export *` — a star re-export of an external
4
4
  // package emits no binding in the bundle.
5
+ // The playback glyphs ride along for the same reason: chrome that labels a control with
6
+ // one should not need `@weasel-js/ui` in its manifest to draw a play button.
5
7
  export type {
6
8
  ButtonProps,
7
9
  ButtonSize,
@@ -13,6 +15,9 @@ export type {
13
15
  EffectCardListItem,
14
16
  EffectCardListProps,
15
17
  EffectCardProps,
18
+ IconProps,
19
+ InputProps,
20
+ JogProps,
16
21
  LayerStackItem,
17
22
  LayerStackProps,
18
23
  NumberRowProps,
@@ -25,6 +30,7 @@ export type {
25
30
  PropertyRowProps,
26
31
  PropertyRowVariant,
27
32
  PropertySpanProps,
33
+ SelectProps,
28
34
  SelectRowProps,
29
35
  SliderRowProps,
30
36
  SubpanelProps,
@@ -34,27 +40,48 @@ export type {
34
40
  ToggleBarSize,
35
41
  ToggleBarVariant,
36
42
  ToggleRowProps,
43
+ TokenCategory,
44
+ TokenEntry,
45
+ TokenPanelProps,
46
+ TokenScale,
47
+ TokenScaleRule,
37
48
  } from '@weasel-js/ui';
38
49
  export {
39
50
  Button,
40
51
  CheckboxRow,
52
+ CloseIcon,
53
+ CollapseIcon,
41
54
  ColorRow,
42
55
  CurveField,
43
56
  EffectCard,
44
57
  EffectCardList,
58
+ ExpandIcon,
59
+ Icon,
60
+ Input,
61
+ inferTokenType,
62
+ Jog,
45
63
  LayerStack,
46
64
  NumberRow,
65
+ PauseIcon,
66
+ PlayIcon,
47
67
  PropertyGroup,
48
68
  PropertyList,
49
69
  PropertyPanel,
50
70
  PropertyRow,
51
71
  PropertySpan,
72
+ refitScale,
73
+ ResetIcon,
74
+ Select,
52
75
  SelectRow,
53
76
  SliderRow,
77
+ StepBackIcon,
78
+ StepForwardIcon,
79
+ StopIcon,
54
80
  Subpanel,
55
81
  TextRow,
56
82
  ToggleBar,
57
83
  ToggleRow,
84
+ TokenPanel,
58
85
  } from '@weasel-js/ui';
59
86
  export type {
60
87
  Annotation,
@@ -124,13 +151,9 @@ export type {
124
151
  CanvasStackContextValue,
125
152
  CanvasStackProps,
126
153
  CanvasStackSurface,
127
- OrbitHandlers,
128
- OrbitView,
129
154
  PanZoomHandlers,
130
155
  StageProps,
131
- UseOrbitOptions,
132
156
  UsePanZoomOptions,
133
- Vec3,
134
157
  ViewportSize,
135
158
  WorldFrame,
136
159
  WorldSpec,
@@ -141,22 +164,18 @@ export {
141
164
  CanvasStack,
142
165
  CanvasStackContext,
143
166
  centerOn,
144
- clampPitch,
145
167
  DEFAULT_FRAME,
146
168
  fitStage,
147
- orbitAfterDrag,
148
- orbitAfterWheel,
149
- PITCH_LIMIT,
150
169
  resolveFrame,
151
170
  Stage,
152
171
  screenToWorld,
153
- useOrbit,
154
172
  usePanZoom,
155
173
  worldToScreen,
156
- wrapYaw,
157
174
  zoomAt,
158
175
  } from './canvas';
159
176
  export * from './chrome';
177
+ export { type Auto, auto, isAuto } from './config/auto';
178
+ export { autoPathsOf, resolveAutoConfig } from './config/autoConfig';
160
179
  export {
161
180
  BaseNode,
162
181
  BooleanNode,
@@ -205,6 +224,7 @@ export type {
205
224
  ValueAtPath,
206
225
  } from './config/types';
207
226
  export { useConfigSchema } from './config/useConfigSchema';
227
+ export { useResolvedConfig } from './config/useResolvedConfig';
208
228
  export { isLeafVisible } from './config/visible';
209
229
  export { ControlPanel } from './controls/ControlPanel';
210
230
  export type {
@@ -246,6 +266,7 @@ export type { LayerListProps, LayerTreeNode } from './layers';
246
266
  export { LayerList } from './layers';
247
267
  export * from './loupe';
248
268
  export * from './primitives';
269
+ export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
249
270
  export {
250
271
  createIndexedDbAdapter,
251
272
  createMemoryAdapter,
@@ -270,6 +291,7 @@ export { Persistence, type PersistenceProps } from './state/Persistence';
270
291
  export type { RecordCache, RecordChange } from './state/records';
271
292
  export type {
272
293
  CreateLabStoreOptions,
294
+ LabDensity,
273
295
  LabDocument,
274
296
  LabMode,
275
297
  LabStoreState,
@@ -1,5 +1,33 @@
1
+ import { resolveConfigSchema } from '../config/resolve';
2
+ import type { ResolvedConfig } from '../config/types';
1
3
  import type { InstrumentSerializers } from '../state/types';
2
- import type { InstrumentList } from './types';
4
+ import type { Instrument, InstrumentList } from './types';
5
+
6
+ /**
7
+ * One instrument's config schema, resolved without the lab's rules — which
8
+ * neither the store nor a trial composed outside a lab has access to. No rule
9
+ * can reach `autoResolve`, `unpinned` or `manual`, so for reading those the
10
+ * two resolutions agree; anything reading the rendered leaves wants
11
+ * `useConfigSchema` instead. `undefined` for a legacy `configSchema()`
12
+ * instrument, which declares no auto annotations at all.
13
+ */
14
+ export function unruledConfigSchema(
15
+ // biome-ignore lint/suspicious/noExplicitAny: instruments are stored contravariantly; see InstrumentList
16
+ instrument: Instrument<any, any, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
17
+ ): ResolvedConfig | undefined {
18
+ return instrument.config ? resolveConfigSchema(instrument.config, []) : undefined;
19
+ }
20
+
21
+ /** Each instrument's unruled config schema, keyed by name — what the store
22
+ * reads a leaf's auto annotations off. */
23
+ export function configSchemasOf(instruments: InstrumentList): Record<string, ResolvedConfig> {
24
+ const out: Record<string, ResolvedConfig> = {};
25
+ for (const instrument of instruments) {
26
+ const schema = unruledConfigSchema(instrument);
27
+ if (schema) out[instrument.name] = schema;
28
+ }
29
+ return out;
30
+ }
3
31
 
4
32
  /**
5
33
  * Each instrument's default config, keyed by name — what `createLabStore`
@@ -62,4 +62,20 @@ describe('<Lab> chrome', () => {
62
62
  render(<Lab title="T" instruments={[withChrome]} defaultInstrument="Bare" />);
63
63
  expect(screen.getByText('ready')).toBeInTheDocument();
64
64
  });
65
+
66
+ it('renders an aside contribution in its own pane, after the workspace', () => {
67
+ render(
68
+ <Lab
69
+ title="T"
70
+ instruments={[bare]}
71
+ defaultInstrument="Bare"
72
+ labChrome={[{ id: 'notes', region: 'aside', item: { title: 'Notes', body: <p>jot</p> } }]}
73
+ />,
74
+ );
75
+ const body = screen.getByText('jot');
76
+ const trial = screen.getByRole('region', { name: /trial/i });
77
+ expect(body.closest('.lk-lab__aside')).not.toBeNull();
78
+ expect(trial.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
79
+ expect(screen.getByRole('separator', { name: /Lab aside/ })).toBeInTheDocument();
80
+ });
65
81
  });
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { useEffect } from 'react';
3
3
  import type { Instrument } from '../instrument/types';
4
4
  import { Lab } from './Lab';
@@ -12,7 +12,12 @@ 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 { useSurfaceCanvas, useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
15
+ import {
16
+ useSurfaceCanvas,
17
+ useSurfaceOptional,
18
+ useSurfaceTile,
19
+ useTileId,
20
+ } from '../surface/useSurfaceTile';
16
21
  import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
17
22
  import { useTiledSurface } from '../surface/useTiledSurface';
18
23
  import { Lab } from './Lab';
@@ -203,7 +208,7 @@ describe('<Lab> tile round trip', () => {
203
208
  });
204
209
 
205
210
  describe('<Lab> pages', () => {
206
- it('gives the title a way back to the project\'s other labs', () => {
211
+ it("gives the title a way back to the project's other labs", () => {
207
212
  const { getByRole } = render(
208
213
  <Lab
209
214
  instruments={[probeInstrument]}
@@ -1,4 +1,4 @@
1
- import { act, render, screen, waitFor } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
2
  import { type ReactNode, useEffect } from 'react';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import type { Instrument } from '../instrument/types';
@@ -238,3 +238,81 @@ describe('instruments that change while mounted', () => {
238
238
  expect(mounts).toBe(1);
239
239
  });
240
240
  });
241
+
242
+ describe('focused trial', () => {
243
+ const regions = () => screen.getAllByRole('region', { name: /trial/i });
244
+ const framed: Instrument = {
245
+ ...stub,
246
+ name: 'Framed',
247
+ render: () => <iframe title="story frame" />,
248
+ };
249
+
250
+ it('is the only trial until another opens, then the one that opened', () => {
251
+ mountLab();
252
+ expect(labRef?.focusedTrialId).toBe(labRef?.trials[0]?.id);
253
+ act(() => labRef?.addTrial('StubB'));
254
+ expect(labRef?.focusedTrialId).toBe(labRef?.trials[1]?.id);
255
+ act(() => labRef?.cloneTrial(labRef?.trials[0]?.id ?? ''));
256
+ expect(labRef?.focusedTrialId).toBe(labRef?.trials[1]?.id);
257
+ expect(labRef?.trials.map((t) => t.instrumentName)).toEqual(['Stub', 'Stub', 'StubB']);
258
+ });
259
+
260
+ it('moves to a trial a pointer goes down in', () => {
261
+ mountLab();
262
+ act(() => labRef?.addTrial('StubB'));
263
+ fireEvent.pointerDown(within(regions()[0] as HTMLElement).getByTestId('stub-content'));
264
+ expect(labRef?.focusedTrialId).toBe(labRef?.trials[0]?.id);
265
+ });
266
+
267
+ it('moves to a trial focus moves into', () => {
268
+ mountLab();
269
+ act(() => labRef?.addTrial('StubB'));
270
+ act(() => (regions()[0] as HTMLElement).focus());
271
+ expect(labRef?.focusedTrialId).toBe(labRef?.trials[0]?.id);
272
+ });
273
+
274
+ it('moves to a trial whose frame takes focus, which fires nothing in the lab’s document', async () => {
275
+ mountLab({ instruments: [framed, stubB], defaultInstrument: 'Framed' });
276
+ act(() => labRef?.addTrial('StubB'));
277
+ act(() => screen.getByTitle('story frame').focus());
278
+ fireEvent.blur(window);
279
+ await waitFor(() => expect(labRef?.focusedTrialId).toBe(labRef?.trials[0]?.id));
280
+ });
281
+
282
+ it('falls back to the first trial when the focused one closes', () => {
283
+ mountLab();
284
+ act(() => labRef?.addTrial('StubB'));
285
+ act(() => labRef?.closeTrial(labRef?.trials[1]?.id ?? ''));
286
+ expect(labRef?.focusedTrialId).toBe(labRef?.trials[0]?.id);
287
+ });
288
+
289
+ it('is marked on its trial only when there is more than one', () => {
290
+ mountLab();
291
+ expect(regions()[0]).not.toHaveAttribute('data-focused');
292
+ act(() => labRef?.addTrial('StubB'));
293
+ expect(regions()[1]).toHaveAttribute('data-focused', 'true');
294
+ expect(regions()[0]).not.toHaveAttribute('data-focused');
295
+ });
296
+ });
297
+
298
+ describe('swapTrial', () => {
299
+ it('runs another instrument in the trial’s place, and keeps focus on that place', () => {
300
+ mountLab();
301
+ act(() => labRef?.addTrial('Stub'));
302
+ const second = labRef?.trials[1]?.id;
303
+ act(() => labRef?.focusTrial(labRef?.trials[0]?.id ?? ''));
304
+ act(() => labRef?.swapTrial(labRef?.trials[0]?.id ?? '', 'StubB'));
305
+ expect(labRef?.trials.map((t) => t.instrumentName)).toEqual(['StubB', 'Stub']);
306
+ expect(labRef?.trials[1]?.id).toBe(second);
307
+ expect(labRef?.focusedTrialId).toBe(labRef?.trials[0]?.id);
308
+ expect(screen.getByTestId('stub-b-content')).toBeInTheDocument();
309
+ });
310
+
311
+ it('leaves focus where it was when the swapped trial did not have it', () => {
312
+ mountLab();
313
+ act(() => labRef?.addTrial('Stub'));
314
+ const focused = labRef?.focusedTrialId;
315
+ act(() => labRef?.swapTrial(labRef?.trials[0]?.id ?? '', 'StubB'));
316
+ expect(labRef?.focusedTrialId).toBe(focused);
317
+ });
318
+ });
package/src/lab/Lab.tsx CHANGED
@@ -13,6 +13,7 @@ import { useStore } from 'zustand/react';
13
13
  import { AnnotationPreloadContext } from '../annotations/preload';
14
14
  import { ANNOTATION_TOOLS } from '../annotations/toolMap';
15
15
  import {
16
+ LabAsideRegion,
16
17
  LabFooterRegion,
17
18
  LabHeaderRegion,
18
19
  LabSidebarRegion,
@@ -29,7 +30,7 @@ import { type OpenedLabStore, openLabStore } from '../state/openLabStore';
29
30
  import { PersistenceContext } from '../state/Persistence';
30
31
  import { createRecordCache } from '../state/records';
31
32
  import { createLabStore, type LabStore } from '../state/store';
32
- import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
33
+ import type { LabDensity, LabMode, StorageAdapter, TrialRecord } from '../state/types';
33
34
  import { useOpenOnce, useWarnIgnoredChange } from '../state/useOpenOnce';
34
35
  import { usePersistedState } from '../state/usePersistedState';
35
36
  import { SurfaceCanvasContext, SurfaceContext } from '../surface/SurfaceContext';
@@ -44,6 +45,7 @@ import {
44
45
  closeTrial as closeTrialOp,
45
46
  reorderTrials as reorderTrialsOp,
46
47
  resetTrial as resetTrialOp,
48
+ swapTrial as swapTrialOp,
47
49
  } from '../trial/trialOps';
48
50
  import { useLabFitWarning } from './fitCheck';
49
51
  import { LabContext, type LabContextValue } from './LabContext';
@@ -62,6 +64,10 @@ interface LabBaseProps {
62
64
  instruments: InstrumentList;
63
65
  defaultInstrument: string;
64
66
  mode?: LabMode;
67
+ /** How much room the lab's chrome takes. Default `'comfortable'`; a lab whose
68
+ * window is the whole app, rather than a panel beside one, reads better at
69
+ * `'roomy'`. The trials' own contents are unaffected. */
70
+ density?: LabDensity;
65
71
  /** Rendered while a stored lab loads. Default: the lab's empty shell. */
66
72
  fallback?: ReactNode;
67
73
  /**
@@ -173,12 +179,17 @@ async function openStoredLab(
173
179
  function LabFallback({
174
180
  title,
175
181
  mode,
182
+ density,
176
183
  pages,
177
184
  path,
178
- }: Pick<LabBaseProps, 'title' | 'mode' | 'pages' | 'path'>) {
185
+ }: Pick<LabBaseProps, 'title' | 'mode' | 'density' | 'pages' | 'path'>) {
179
186
  const resolvedMode = useResolvedMode(mode ?? 'auto');
180
187
  return (
181
- <ThemeProvider theme={interstellarTheme} mode={resolvedMode} className="lk-lab">
188
+ <ThemeProvider
189
+ theme={interstellarTheme}
190
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
191
+ className="lk-lab"
192
+ >
182
193
  <LabShell
183
194
  title={title ?? 'Labkit'}
184
195
  mode={mode}
@@ -228,6 +239,32 @@ function LabPanes({
228
239
  undefined,
229
240
  { scope: 'lab' },
230
241
  );
242
+ const [asideWidth, setAsideWidth] = usePersistedState<number | undefined>(
243
+ 'lk-lab-aside-width',
244
+ undefined,
245
+ { scope: 'lab' },
246
+ );
247
+ const main = contributions.some((c) => c.region === 'aside') ? (
248
+ <Split
249
+ className="lk-lab__panes"
250
+ side="end"
251
+ sidebarClassName="lk-lab__aside"
252
+ contentClassName="lk-lab__main"
253
+ label="Lab aside"
254
+ defaultWidth={320}
255
+ width={asideWidth}
256
+ onWidthChange={(w) => {
257
+ setAsideWidth(w);
258
+ surface?.invalidateRects();
259
+ }}
260
+ sidebar={<LabAsideRegion contributions={contributions} />}
261
+ >
262
+ {children}
263
+ </Split>
264
+ ) : (
265
+ children
266
+ );
267
+ if (!contributions.some((c) => c.region === 'sidebar')) return main;
231
268
  return (
232
269
  <Split
233
270
  className="lk-lab__panes"
@@ -242,7 +279,7 @@ function LabPanes({
242
279
  }}
243
280
  sidebar={<LabSidebarRegion contributions={contributions} />}
244
281
  >
245
- {children}
282
+ {main}
246
283
  </Split>
247
284
  );
248
285
  }
@@ -259,10 +296,15 @@ export function Lab(props: LabProps) {
259
296
  storageKey === undefined ? openUnstoredLab(props) : openStoredLab(props, storageKey, storage),
260
297
  );
261
298
  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} />
299
+ if (props.fallback !== undefined) return props.fallback;
300
+ return (
301
+ <LabFallback
302
+ title={props.title}
303
+ mode={props.mode}
304
+ density={props.density}
305
+ pages={props.pages}
306
+ path={props.path}
307
+ />
266
308
  );
267
309
  }
268
310
  return <LabRuntime {...props} opened={opened} />;
@@ -271,6 +313,7 @@ export function Lab(props: LabProps) {
271
313
  function LabRuntime({
272
314
  instruments,
273
315
  mode,
316
+ density,
274
317
  nebula,
275
318
  title,
276
319
  pages,
@@ -295,6 +338,10 @@ function LabRuntime({
295
338
  }, [instruments, store]);
296
339
 
297
340
  const trials = useStore(store, (s) => s.trials);
341
+ const [focusPick, setFocusPick] = useState<string | null>(null);
342
+ const focusedTrialId = trials.some((t) => t.id === focusPick)
343
+ ? focusPick
344
+ : (trials[0]?.id ?? null);
298
345
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
299
346
  const modeValue = useStore(store, (s) => s.mode);
300
347
  const layout = useStore(store, (s) => s.layout);
@@ -359,6 +406,39 @@ function LabRuntime({
359
406
  [outerSurface, attachOwnSurface],
360
407
  );
361
408
  useLabFitWarning(labBody);
409
+
410
+ useEffect(() => {
411
+ if (!labBody) return;
412
+ const pick = (node: unknown): void => {
413
+ const owned = new Set(store.getState().trials.map((t) => t.id));
414
+ const nearest = (from: Element | null | undefined) =>
415
+ from?.closest<HTMLElement>('.lk-trial[data-trial-id]') ?? null;
416
+ let trial =
417
+ typeof (node as Element | null)?.closest === 'function' ? nearest(node as Element) : null;
418
+ // A trial can hold a lab of its own, whose trials this lab does not own.
419
+ while (trial && !owned.has(trial.dataset.trialId ?? '')) trial = nearest(trial.parentElement);
420
+ if (trial?.dataset.trialId) setFocusPick(trial.dataset.trialId);
421
+ };
422
+ const onInput = (event: Event): void => pick(event.target);
423
+ // Focus moving into a frame fires nothing in this document: the window
424
+ // blurs, and the frame is the active element once it has.
425
+ let pending: ReturnType<typeof setTimeout> | undefined;
426
+ const doc = labBody.ownerDocument;
427
+ const win = doc.defaultView;
428
+ const onBlur = (): void => {
429
+ clearTimeout(pending);
430
+ pending = setTimeout(() => pick(doc.activeElement), 0);
431
+ };
432
+ labBody.addEventListener('pointerdown', onInput, true);
433
+ labBody.addEventListener('focusin', onInput, true);
434
+ win?.addEventListener('blur', onBlur);
435
+ return () => {
436
+ clearTimeout(pending);
437
+ labBody.removeEventListener('pointerdown', onInput, true);
438
+ labBody.removeEventListener('focusin', onInput, true);
439
+ win?.removeEventListener('blur', onBlur);
440
+ };
441
+ }, [labBody, store]);
362
442
  const surface = outerSurface ?? ownSurface;
363
443
  const surfaceCanvases = useMemo(
364
444
  () =>
@@ -388,7 +468,7 @@ function LabRuntime({
388
468
  [annotates, tools, labChrome],
389
469
  );
390
470
  const hasFooterChrome = labChromeAll.some((c) => c.region === 'footer');
391
- const hasSidebarChrome = labChromeAll.some((c) => c.region === 'sidebar');
471
+ const hasPaneChrome = labChromeAll.some((c) => c.region === 'sidebar' || c.region === 'aside');
392
472
 
393
473
  useEffect(() => {
394
474
  if (mode && mode !== store.getState().mode) {
@@ -402,17 +482,23 @@ function LabRuntime({
402
482
  const merged = next.map((w) => currentById.get(w.id) ?? w);
403
483
  store.setState({ trials: merged });
404
484
  };
485
+ const replaceAndFocusAdded = (next: TrialRecord[]): void => {
486
+ const before = new Set(store.getState().trials.map((w) => w.id));
487
+ replaceTrials(next);
488
+ const added = next.find((w) => !before.has(w.id));
489
+ if (added) setFocusPick(added.id);
490
+ };
405
491
 
406
492
  return {
407
493
  instruments,
408
494
  trials,
409
495
  addTrial: (instrumentName, options) => {
410
- const next = addTrialOp(store.getState().trials, instruments, instrumentName, options);
411
- replaceTrials(next);
496
+ replaceAndFocusAdded(
497
+ addTrialOp(store.getState().trials, instruments, instrumentName, options),
498
+ );
412
499
  },
413
500
  cloneTrial: (id) => {
414
- const next = cloneTrialOp(store.getState().trials, id);
415
- replaceTrials(next);
501
+ replaceAndFocusAdded(cloneTrialOp(store.getState().trials, id));
416
502
  },
417
503
  closeTrial: (id) => {
418
504
  const next = closeTrialOp(store.getState().trials, id);
@@ -421,6 +507,19 @@ function LabRuntime({
421
507
  reorderTrials: (ids) => {
422
508
  replaceTrials(reorderTrialsOp(store.getState().trials, ids));
423
509
  },
510
+ swapTrial: (id, instrumentName, options) => {
511
+ const current = store.getState();
512
+ const next = swapTrialOp(current.trials, instruments, id, instrumentName, options);
513
+ if (next === current.trials) return;
514
+ const record = next[current.trials.findIndex((w) => w.id === id)] as TrialRecord;
515
+ // Written before the tile registers, which is when the grid reads it.
516
+ const { [id]: extent, ...layoutRest } = current.layout as TrialLayout;
517
+ if (extent) current.setLayout({ ...layoutRest, [record.id]: extent });
518
+ replaceTrials(next);
519
+ if (focusedTrialId === id) setFocusPick(record.id);
520
+ },
521
+ focusedTrialId,
522
+ focusTrial: (id) => setFocusPick(id),
424
523
  resetTrial: (id) => {
425
524
  const next = resetTrialOp(store.getState().trials, id, instruments);
426
525
  const record = next.find((w) => w.id === id);
@@ -451,7 +550,16 @@ function LabRuntime({
451
550
  configRules,
452
551
  controls,
453
552
  };
454
- }, [instruments, trials, savedSnapshots, modeValue, store, configRules, controls]);
553
+ }, [
554
+ instruments,
555
+ trials,
556
+ focusedTrialId,
557
+ savedSnapshots,
558
+ modeValue,
559
+ store,
560
+ configRules,
561
+ controls,
562
+ ]);
455
563
 
456
564
  // Only override the backdrop in dark, where there is one to override.
457
565
  // Setting a CSS custom property is the sanctioned use of inline style.
@@ -483,7 +591,7 @@ function LabRuntime({
483
591
  <LabContext.Provider value={contextValue}>
484
592
  <ThemeProvider
485
593
  theme={interstellarTheme}
486
- mode={resolvedMode}
594
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
487
595
  className="lk-lab"
488
596
  style={backdropStyle}
489
597
  >
@@ -537,7 +645,7 @@ function LabRuntime({
537
645
  />
538
646
  </>
539
647
  )}
540
- {hasSidebarChrome ? (
648
+ {hasPaneChrome ? (
541
649
  <LabPanes contributions={labChromeAll}>
542
650
  <LabPalette contributions={labChromeAll} />
543
651
  {workspace}
@@ -18,6 +18,14 @@ export interface LabContextValue {
18
18
  closeTrial: (id: string) => void;
19
19
  resetTrial: (id: string) => void;
20
20
  reorderTrials: (ids: readonly string[]) => void;
21
+ /** Put a fresh trial running `instrumentName` in trial `id`'s place, at its
22
+ * size. The replacement has a new id, and takes the focus if `id` had it. */
23
+ swapTrial: (id: string, instrumentName: string, options?: AddTrialOptions) => void;
24
+ /** The trial last pointed at or focused, counting focus that moved into a
25
+ * frame inside it. A trial `addTrial` or `cloneTrial` opens takes it. Falls
26
+ * back to the first trial; null only when there are none. */
27
+ focusedTrialId: string | null;
28
+ focusTrial: (id: string) => void;
21
29
  savedSnapshots: SavedSnapshot[];
22
30
  saveSnapshot: (trialId: string, name?: string) => void;
23
31
  loadSnapshot: (trialId: string, snapshotId: string) => void;
@@ -1,7 +1,7 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { expect, userEvent, waitFor, within } from '@weasel-js/forge/play';
2
3
  import { type ReactNode, useLayoutEffect, useState } from 'react';
3
4
  import { createPortal } from 'react-dom';
4
- import { expect, userEvent, waitFor, within } from 'storybook/test';
5
5
  import type { LabContribution } from '../chrome/labTypes';
6
6
  import type { TrialContribution } from '../chrome/types';
7
7
  import type { Instrument } from '../instrument/types';
@@ -9,7 +9,7 @@ import { Lab } from './Lab';
9
9
  import './LabFit.stories.less';
10
10
 
11
11
  // A lab must fit whatever it is mounted in without scrolling. jsdom cannot see
12
- // layout, so these run in the storybook vitest project's real browser.
12
+ // layout, so these run in the forge-stories vitest project's real browser.
13
13
 
14
14
  const Stub: Instrument = {
15
15
  name: 'Stub',
@@ -43,7 +43,7 @@ const VIEWPORTS = {
43
43
 
44
44
  type Host = 'reset' | 'wrapped' | 'framed';
45
45
 
46
- // Storybook wraps every story in divs of its own, so a fixture that needs a
46
+ // The frame wraps every story in divs of its own, so a fixture that needs a
47
47
  // known ancestor chain builds that chain directly on <body> and portals into it.
48
48
  function BodyHost({ host, children }: { host: Host; children: ReactNode }) {
49
49
  const [mount, setMount] = useState<HTMLElement | null>(null);
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
2
  import { useEffect, useRef } from 'react';
3
3
  import type { ConfigField } from '../controls/types';
4
4
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -48,7 +48,6 @@ export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
48
48
  <ToggleBar
49
49
  className="lk-lab-header__mode"
50
50
  ariaLabel="Color mode"
51
- size="sm"
52
51
  variant="flat"
53
52
  items={MODE_ITEMS}
54
53
  value={lab.mode}