@weasel-js/labkit 1.5.0 → 1.5.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 (192) hide show
  1. package/README.md +41 -4
  2. package/dist/_dts/{CanvasStackContext-CqkxkFJW.d.ts → CanvasStackContext-CJNMS6mz.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-Gwr8fLpK.d.ts +453 -0
  5. package/dist/_dts/{frac-8R6UmlvN.d.ts → frac-B4okC2lX.d.ts} +42 -4
  6. package/dist/_dts/{index-retnAut7.d.ts → index-BNdz-tNd.d.ts} +90 -10
  7. package/dist/_dts/{index-B8uu2ba7.d.ts → index-CSJoj4LC.d.ts} +17 -6
  8. package/dist/_dts/{types-Ca2LCnPq.d.ts → types-DzoNY1pO.d.ts} +28 -4
  9. package/dist/_dts/{usePanZoom-nS798rOF.d.ts → usePanZoom-Pzf9f2jh.d.ts} +2 -2
  10. package/dist/_dts/{useTrialState-jcx_TdU6.d.ts → useTrialState-uLlmaccn.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-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-PGETSEDK.js → chunk-CEIUBPPW.js} +10 -6
  18. package/dist/chunk-CEIUBPPW.js.map +1 -0
  19. package/dist/chunk-EH7UEQV7.js +590 -0
  20. package/dist/chunk-EH7UEQV7.js.map +1 -0
  21. package/dist/{chunk-EKIICY6X.js → chunk-EHGVROVV.js} +37 -25
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-ZPH5WTWF.js → chunk-ERFMC6WU.js} +142 -66
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-NS64DXMN.js → chunk-IHUVQ3TX.js} +39 -16
  26. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  27. package/dist/{chunk-QLSV2N3G.js → chunk-IJJUK4FU.js} +25 -13
  28. package/dist/chunk-IJJUK4FU.js.map +1 -0
  29. package/dist/{chunk-67SJLMC7.js → chunk-IXTQI7JX.js} +45 -12
  30. package/dist/chunk-IXTQI7JX.js.map +1 -0
  31. package/dist/{chunk-MUKOW3TC.js → chunk-MDVTK5U3.js} +4 -4
  32. package/dist/{chunk-MUKOW3TC.js.map → chunk-MDVTK5U3.js.map} +1 -1
  33. package/dist/{chunk-UU3NJO6N.js → chunk-PHK4W562.js} +3 -3
  34. package/dist/{chunk-UU3NJO6N.js.map → chunk-PHK4W562.js.map} +1 -1
  35. package/dist/chunk-PJ7WEQBJ.js +18 -0
  36. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  37. package/dist/chunk-T3EC2DXD.js +12628 -0
  38. package/dist/chunk-T3EC2DXD.js.map +1 -0
  39. package/dist/{chunk-KJALCDWE.js → chunk-UN4U4P63.js} +4 -4
  40. package/dist/{chunk-KJALCDWE.js.map → chunk-UN4U4P63.js.map} +1 -1
  41. package/dist/{chunk-NDRYLVOW.js → chunk-Z76KEZAX.js} +2 -2
  42. package/dist/chunk-Z76KEZAX.js.map +1 -0
  43. package/dist/{chunk-6ZDGOZQV.js → chunk-ZDHY47EV.js} +70 -17
  44. package/dist/chunk-ZDHY47EV.js.map +1 -0
  45. package/dist/config/index.d.ts +60 -16
  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 +177 -53
  51. package/dist/index.js +1333 -1683
  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 +26 -202
  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 +153 -40
  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/usePanZoom.test.ts +28 -0
  77. package/src/canvas/usePanZoom.ts +11 -4
  78. package/src/chrome/ChromeRegions.stories.tsx +1 -1
  79. package/src/chrome/LabChrome.tsx +21 -5
  80. package/src/chrome/builtins.test.ts +1 -0
  81. package/src/chrome/builtins.tsx +1 -0
  82. package/src/chrome/index.ts +1 -0
  83. package/src/chrome/labTypes.ts +2 -2
  84. package/src/chrome/regions/PaletteRegion.tsx +1 -6
  85. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +11 -7
  87. package/src/chrome/types.ts +3 -0
  88. package/src/config/auto.test.ts +12 -0
  89. package/src/config/auto.ts +15 -0
  90. package/src/config/autoConfig.test.ts +107 -0
  91. package/src/config/autoConfig.ts +153 -0
  92. package/src/config/builder.test.ts +38 -0
  93. package/src/config/builder.ts +53 -8
  94. package/src/config/index.ts +3 -0
  95. package/src/config/path.ts +7 -2
  96. package/src/config/resolve.test.ts +25 -0
  97. package/src/config/resolve.ts +17 -1
  98. package/src/config/types.ts +32 -2
  99. package/src/config/useResolvedConfig.ts +26 -0
  100. package/src/controls/AutoControls.stories.tsx +88 -0
  101. package/src/controls/ControlPanel.stories.tsx +42 -1
  102. package/src/controls/ControlPanel.test.tsx +514 -4
  103. package/src/controls/ControlPanel.tsx +557 -80
  104. package/src/index.ts +29 -1
  105. package/src/instrument/serializers.ts +29 -1
  106. package/src/lab/Lab.chrome.test.tsx +16 -0
  107. package/src/lab/Lab.stories.tsx +1 -1
  108. package/src/lab/Lab.surface.test.tsx +7 -2
  109. package/src/lab/Lab.test.tsx +79 -1
  110. package/src/lab/Lab.tsx +105 -13
  111. package/src/lab/LabContext.ts +8 -0
  112. package/src/lab/LabFit.stories.tsx +4 -4
  113. package/src/lab/LabFullChrome.stories.tsx +1 -1
  114. package/src/lab/LabHeader.tsx +0 -1
  115. package/src/lab/LabRoot.test.tsx +56 -0
  116. package/src/lab/LabRoot.tsx +47 -0
  117. package/src/lab/LabShell.less +24 -3
  118. package/src/lab/LabShell.stories.tsx +50 -1
  119. package/src/lab/LabShell.tsx +18 -27
  120. package/src/lab/LabSwitcher.less +10 -10
  121. package/src/lab/LabSwitcher.test.tsx +14 -9
  122. package/src/lab/LabSwitcher.tsx +8 -4
  123. package/src/lab/Workspace.less +1 -1
  124. package/src/lab/Workspace.stories.tsx +1 -1
  125. package/src/lab/Workspace.surface.test.tsx +1 -1
  126. package/src/lab/index.ts +2 -0
  127. package/src/layers/LayerList.stories.tsx +1 -1
  128. package/src/loupe/AGENTS.md +16 -4
  129. package/src/loupe/LoupeGestures.tsx +67 -0
  130. package/src/loupe/TrialLoupe.tsx +43 -28
  131. package/src/loupe/index.ts +4 -0
  132. package/src/loupe/loupeActions.ts +98 -0
  133. package/src/loupe/useLoupe.test.tsx +59 -2
  134. package/src/loupe/useLoupe.ts +28 -48
  135. package/src/passthrough/weasel-ui.ts +3 -3
  136. package/src/primitives/FloatingPanel.stories.tsx +1 -1
  137. package/src/primitives/FpsMeter.stories.tsx +1 -1
  138. package/src/primitives/JobProgress.stories.tsx +1 -1
  139. package/src/primitives/Legend.stories.tsx +1 -1
  140. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  141. package/src/primitives/Sidebar.less +7 -0
  142. package/src/primitives/Sidebar.stories.tsx +1 -1
  143. package/src/primitives/Split.test.tsx +25 -0
  144. package/src/primitives/Split.tsx +36 -20
  145. package/src/primitives/StatusBar.stories.tsx +1 -1
  146. package/src/primitives/Toolbar.stories.tsx +1 -1
  147. package/src/specimen/Specimen.less +95 -0
  148. package/src/specimen/Specimen.stories.tsx +13 -0
  149. package/src/specimen/Specimen.test.tsx +13 -0
  150. package/src/specimen/Specimen.tsx +888 -0
  151. package/src/state/Persistence.stories.tsx +4 -4
  152. package/src/state/store.test.ts +65 -0
  153. package/src/state/store.ts +32 -8
  154. package/src/state/types.ts +26 -2
  155. package/src/state/useTrialState.test.tsx +85 -0
  156. package/src/state/useTrialState.ts +9 -1
  157. package/src/styles.less +1 -0
  158. package/src/surface/index.ts +1 -1
  159. package/src/surface/useSurfaceTile.test.tsx +1 -1
  160. package/src/surface/useTiledSurface.test.tsx +65 -10
  161. package/src/surface/useTiledSurface.ts +23 -11
  162. package/src/theme/Interstellar.stories.tsx +2 -2
  163. package/src/theme/interstellar.test.ts +16 -5
  164. package/src/theme/interstellar.theme.json +180 -0
  165. package/src/theme/interstellar.ts +8 -5
  166. package/src/tools/labTool.ts +3 -3
  167. package/src/trial/Trial.annotations.test.tsx +13 -4
  168. package/src/trial/Trial.canvas.test.tsx +32 -0
  169. package/src/trial/Trial.config.test.tsx +75 -2
  170. package/src/trial/Trial.less +4 -0
  171. package/src/trial/Trial.stories.tsx +4 -1
  172. package/src/trial/Trial.test.tsx +3 -0
  173. package/src/trial/Trial.tsx +21 -14
  174. package/src/trial/TrialChrome.tsx +17 -3
  175. package/src/trial/trialOps.test.ts +101 -1
  176. package/src/trial/trialOps.ts +62 -4
  177. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +0 -116
  178. package/dist/chunk-67SJLMC7.js.map +0 -1
  179. package/dist/chunk-6ZDGOZQV.js.map +0 -1
  180. package/dist/chunk-E44SU6XS.js +0 -50
  181. package/dist/chunk-E44SU6XS.js.map +0 -1
  182. package/dist/chunk-EKIICY6X.js.map +0 -1
  183. package/dist/chunk-NDRYLVOW.js.map +0 -1
  184. package/dist/chunk-NS64DXMN.js.map +0 -1
  185. package/dist/chunk-PGETSEDK.js.map +0 -1
  186. package/dist/chunk-QLSV2N3G.js.map +0 -1
  187. package/dist/chunk-S2SHZRD7.js +0 -7681
  188. package/dist/chunk-S2SHZRD7.js.map +0 -1
  189. package/dist/chunk-TQBBYTAF.js +0 -280
  190. package/dist/chunk-TQBBYTAF.js.map +0 -1
  191. package/dist/chunk-ZPH5WTWF.js.map +0 -1
  192. package/src/theme/interstellar.tokens.json +0 -61
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,45 @@ export type {
34
40
  ToggleBarSize,
35
41
  ToggleBarVariant,
36
42
  ToggleRowProps,
43
+ TokenCategory,
44
+ TokenEntry,
45
+ TokenPanelProps,
37
46
  } from '@weasel-js/ui';
38
47
  export {
39
48
  Button,
40
49
  CheckboxRow,
50
+ CloseIcon,
51
+ CollapseIcon,
41
52
  ColorRow,
42
53
  CurveField,
43
54
  EffectCard,
44
55
  EffectCardList,
56
+ ExpandIcon,
57
+ Icon,
58
+ Input,
59
+ inferTokenType,
60
+ Jog,
45
61
  LayerStack,
46
62
  NumberRow,
63
+ PauseIcon,
64
+ PlayIcon,
47
65
  PropertyGroup,
48
66
  PropertyList,
49
67
  PropertyPanel,
50
68
  PropertyRow,
51
69
  PropertySpan,
70
+ ResetIcon,
71
+ Select,
52
72
  SelectRow,
53
73
  SliderRow,
74
+ StepBackIcon,
75
+ StepForwardIcon,
76
+ StopIcon,
54
77
  Subpanel,
55
78
  TextRow,
56
79
  ToggleBar,
57
80
  ToggleRow,
81
+ TokenPanel,
58
82
  } from '@weasel-js/ui';
59
83
  export type {
60
84
  Annotation,
@@ -157,6 +181,8 @@ export {
157
181
  zoomAt,
158
182
  } from './canvas';
159
183
  export * from './chrome';
184
+ export { type Auto, auto, isAuto } from './config/auto';
185
+ export { autoPathsOf, resolveAutoConfig } from './config/autoConfig';
160
186
  export {
161
187
  BaseNode,
162
188
  BooleanNode,
@@ -205,6 +231,7 @@ export type {
205
231
  ValueAtPath,
206
232
  } from './config/types';
207
233
  export { useConfigSchema } from './config/useConfigSchema';
234
+ export { useResolvedConfig } from './config/useResolvedConfig';
208
235
  export { isLeafVisible } from './config/visible';
209
236
  export { ControlPanel } from './controls/ControlPanel';
210
237
  export type {
@@ -246,6 +273,7 @@ export type { LayerListProps, LayerTreeNode } from './layers';
246
273
  export { LayerList } from './layers';
247
274
  export * from './loupe';
248
275
  export * from './primitives';
276
+ export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
249
277
  export {
250
278
  createIndexedDbAdapter,
251
279
  createMemoryAdapter,
@@ -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,
@@ -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';
@@ -178,7 +180,7 @@ function LabFallback({
178
180
  }: Pick<LabBaseProps, 'title' | 'mode' | 'pages' | 'path'>) {
179
181
  const resolvedMode = useResolvedMode(mode ?? 'auto');
180
182
  return (
181
- <ThemeProvider theme={interstellarTheme} mode={resolvedMode} className="lk-lab">
183
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: resolvedMode }} className="lk-lab">
182
184
  <LabShell
183
185
  title={title ?? 'Labkit'}
184
186
  mode={mode}
@@ -228,6 +230,32 @@ function LabPanes({
228
230
  undefined,
229
231
  { scope: 'lab' },
230
232
  );
233
+ const [asideWidth, setAsideWidth] = usePersistedState<number | undefined>(
234
+ 'lk-lab-aside-width',
235
+ undefined,
236
+ { scope: 'lab' },
237
+ );
238
+ const main = contributions.some((c) => c.region === 'aside') ? (
239
+ <Split
240
+ className="lk-lab__panes"
241
+ side="end"
242
+ sidebarClassName="lk-lab__aside"
243
+ contentClassName="lk-lab__main"
244
+ label="Lab aside"
245
+ defaultWidth={320}
246
+ width={asideWidth}
247
+ onWidthChange={(w) => {
248
+ setAsideWidth(w);
249
+ surface?.invalidateRects();
250
+ }}
251
+ sidebar={<LabAsideRegion contributions={contributions} />}
252
+ >
253
+ {children}
254
+ </Split>
255
+ ) : (
256
+ children
257
+ );
258
+ if (!contributions.some((c) => c.region === 'sidebar')) return main;
231
259
  return (
232
260
  <Split
233
261
  className="lk-lab__panes"
@@ -242,7 +270,7 @@ function LabPanes({
242
270
  }}
243
271
  sidebar={<LabSidebarRegion contributions={contributions} />}
244
272
  >
245
- {children}
273
+ {main}
246
274
  </Split>
247
275
  );
248
276
  }
@@ -259,9 +287,8 @@ export function Lab(props: LabProps) {
259
287
  storageKey === undefined ? openUnstoredLab(props) : openStoredLab(props, storageKey, storage),
260
288
  );
261
289
  if (!opened) {
262
- return props.fallback !== undefined ? (
263
- <>{props.fallback}</>
264
- ) : (
290
+ if (props.fallback !== undefined) return props.fallback;
291
+ return (
265
292
  <LabFallback title={props.title} mode={props.mode} pages={props.pages} path={props.path} />
266
293
  );
267
294
  }
@@ -295,6 +322,10 @@ function LabRuntime({
295
322
  }, [instruments, store]);
296
323
 
297
324
  const trials = useStore(store, (s) => s.trials);
325
+ const [focusPick, setFocusPick] = useState<string | null>(null);
326
+ const focusedTrialId = trials.some((t) => t.id === focusPick)
327
+ ? focusPick
328
+ : (trials[0]?.id ?? null);
298
329
  const savedSnapshots = useStore(store, (s) => s.savedSnapshots);
299
330
  const modeValue = useStore(store, (s) => s.mode);
300
331
  const layout = useStore(store, (s) => s.layout);
@@ -359,6 +390,39 @@ function LabRuntime({
359
390
  [outerSurface, attachOwnSurface],
360
391
  );
361
392
  useLabFitWarning(labBody);
393
+
394
+ useEffect(() => {
395
+ if (!labBody) return;
396
+ const pick = (node: unknown): void => {
397
+ const owned = new Set(store.getState().trials.map((t) => t.id));
398
+ const nearest = (from: Element | null | undefined) =>
399
+ from?.closest<HTMLElement>('.lk-trial[data-trial-id]') ?? null;
400
+ let trial =
401
+ typeof (node as Element | null)?.closest === 'function' ? nearest(node as Element) : null;
402
+ // A trial can hold a lab of its own, whose trials this lab does not own.
403
+ while (trial && !owned.has(trial.dataset.trialId ?? '')) trial = nearest(trial.parentElement);
404
+ if (trial?.dataset.trialId) setFocusPick(trial.dataset.trialId);
405
+ };
406
+ const onInput = (event: Event): void => pick(event.target);
407
+ // Focus moving into a frame fires nothing in this document: the window
408
+ // blurs, and the frame is the active element once it has.
409
+ let pending: ReturnType<typeof setTimeout> | undefined;
410
+ const doc = labBody.ownerDocument;
411
+ const win = doc.defaultView;
412
+ const onBlur = (): void => {
413
+ clearTimeout(pending);
414
+ pending = setTimeout(() => pick(doc.activeElement), 0);
415
+ };
416
+ labBody.addEventListener('pointerdown', onInput, true);
417
+ labBody.addEventListener('focusin', onInput, true);
418
+ win?.addEventListener('blur', onBlur);
419
+ return () => {
420
+ clearTimeout(pending);
421
+ labBody.removeEventListener('pointerdown', onInput, true);
422
+ labBody.removeEventListener('focusin', onInput, true);
423
+ win?.removeEventListener('blur', onBlur);
424
+ };
425
+ }, [labBody, store]);
362
426
  const surface = outerSurface ?? ownSurface;
363
427
  const surfaceCanvases = useMemo(
364
428
  () =>
@@ -388,7 +452,7 @@ function LabRuntime({
388
452
  [annotates, tools, labChrome],
389
453
  );
390
454
  const hasFooterChrome = labChromeAll.some((c) => c.region === 'footer');
391
- const hasSidebarChrome = labChromeAll.some((c) => c.region === 'sidebar');
455
+ const hasPaneChrome = labChromeAll.some((c) => c.region === 'sidebar' || c.region === 'aside');
392
456
 
393
457
  useEffect(() => {
394
458
  if (mode && mode !== store.getState().mode) {
@@ -402,17 +466,23 @@ function LabRuntime({
402
466
  const merged = next.map((w) => currentById.get(w.id) ?? w);
403
467
  store.setState({ trials: merged });
404
468
  };
469
+ const replaceAndFocusAdded = (next: TrialRecord[]): void => {
470
+ const before = new Set(store.getState().trials.map((w) => w.id));
471
+ replaceTrials(next);
472
+ const added = next.find((w) => !before.has(w.id));
473
+ if (added) setFocusPick(added.id);
474
+ };
405
475
 
406
476
  return {
407
477
  instruments,
408
478
  trials,
409
479
  addTrial: (instrumentName, options) => {
410
- const next = addTrialOp(store.getState().trials, instruments, instrumentName, options);
411
- replaceTrials(next);
480
+ replaceAndFocusAdded(
481
+ addTrialOp(store.getState().trials, instruments, instrumentName, options),
482
+ );
412
483
  },
413
484
  cloneTrial: (id) => {
414
- const next = cloneTrialOp(store.getState().trials, id);
415
- replaceTrials(next);
485
+ replaceAndFocusAdded(cloneTrialOp(store.getState().trials, id));
416
486
  },
417
487
  closeTrial: (id) => {
418
488
  const next = closeTrialOp(store.getState().trials, id);
@@ -421,6 +491,19 @@ function LabRuntime({
421
491
  reorderTrials: (ids) => {
422
492
  replaceTrials(reorderTrialsOp(store.getState().trials, ids));
423
493
  },
494
+ swapTrial: (id, instrumentName, options) => {
495
+ const current = store.getState();
496
+ const next = swapTrialOp(current.trials, instruments, id, instrumentName, options);
497
+ if (next === current.trials) return;
498
+ const record = next[current.trials.findIndex((w) => w.id === id)] as TrialRecord;
499
+ // Written before the tile registers, which is when the grid reads it.
500
+ const { [id]: extent, ...layoutRest } = current.layout as TrialLayout;
501
+ if (extent) current.setLayout({ ...layoutRest, [record.id]: extent });
502
+ replaceTrials(next);
503
+ if (focusedTrialId === id) setFocusPick(record.id);
504
+ },
505
+ focusedTrialId,
506
+ focusTrial: (id) => setFocusPick(id),
424
507
  resetTrial: (id) => {
425
508
  const next = resetTrialOp(store.getState().trials, id, instruments);
426
509
  const record = next.find((w) => w.id === id);
@@ -451,7 +534,16 @@ function LabRuntime({
451
534
  configRules,
452
535
  controls,
453
536
  };
454
- }, [instruments, trials, savedSnapshots, modeValue, store, configRules, controls]);
537
+ }, [
538
+ instruments,
539
+ trials,
540
+ focusedTrialId,
541
+ savedSnapshots,
542
+ modeValue,
543
+ store,
544
+ configRules,
545
+ controls,
546
+ ]);
455
547
 
456
548
  // Only override the backdrop in dark, where there is one to override.
457
549
  // Setting a CSS custom property is the sanctioned use of inline style.
@@ -483,7 +575,7 @@ function LabRuntime({
483
575
  <LabContext.Provider value={contextValue}>
484
576
  <ThemeProvider
485
577
  theme={interstellarTheme}
486
- mode={resolvedMode}
578
+ selection={{ mode: resolvedMode }}
487
579
  className="lk-lab"
488
580
  style={backdropStyle}
489
581
  >
@@ -537,7 +629,7 @@ function LabRuntime({
537
629
  />
538
630
  </>
539
631
  )}
540
- {hasSidebarChrome ? (
632
+ {hasPaneChrome ? (
541
633
  <LabPanes contributions={labChromeAll}>
542
634
  <LabPalette contributions={labChromeAll} />
543
635
  {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}
@@ -0,0 +1,56 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { ThemeProvider } from '@weasel-js/theme/react';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { interstellarTheme } from '../theme/interstellar';
5
+ import { LabRoot } from './LabRoot';
6
+
7
+ describe('LabRoot', () => {
8
+ test('applies lk-root to a labkit piece mounted on its own', () => {
9
+ const { container } = render(
10
+ <LabRoot>
11
+ <div>bare</div>
12
+ </LabRoot>,
13
+ );
14
+ expect(container.querySelector('.lk-root')).not.toBeNull();
15
+ expect(screen.getByText('bare')).toBeInTheDocument();
16
+ });
17
+
18
+ test('marks lk-root as the portal host for overlays', () => {
19
+ const { container } = render(<LabRoot>x</LabRoot>);
20
+ expect(container.querySelector('.lk-root')?.hasAttribute('data-wzl-portal-host')).toBe(true);
21
+ });
22
+
23
+ test('appends className after lk-root on the same element', () => {
24
+ const { container } = render(<LabRoot className="lk-shell">x</LabRoot>);
25
+ const root = container.querySelector('.lk-root') as HTMLElement;
26
+ expect(root.className).toBe('lk-root lk-shell');
27
+ });
28
+
29
+ test('stamps the requested mode under the interstellar theme', () => {
30
+ const { container } = render(<LabRoot mode="light">x</LabRoot>);
31
+ const root = container.firstChild as HTMLElement;
32
+ expect(root.getAttribute('data-wzl-theme')).toBe('interstellar');
33
+ expect(root.getAttribute('data-wzl-mode')).toBe('light');
34
+ });
35
+
36
+ test('resolves mode="auto" to a concrete mode', () => {
37
+ const { container } = render(<LabRoot>x</LabRoot>);
38
+ const root = container.firstChild as HTMLElement;
39
+ expect(root.getAttribute('data-wzl-mode')).toMatch(/^(light|dark)$/);
40
+ });
41
+
42
+ // The app's theme wins, and no second provider is mounted over it. This
43
+ // only holds while labkit resolves `@weasel-js/theme` to the same copy the
44
+ // app does — it is the in-repo half of the peer-externalization the smoke
45
+ // test's dist audit enforces for a published install.
46
+ test('defers to a theme the app already applied', () => {
47
+ const { container } = render(
48
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: 'dark' }}>
49
+ <LabRoot mode="light">x</LabRoot>
50
+ </ThemeProvider>,
51
+ );
52
+ const root = container.querySelector('.lk-root') as HTMLElement;
53
+ expect(root.getAttribute('data-wzl-mode')).toBeNull();
54
+ expect(container.querySelectorAll('[data-wzl-theme]')).toHaveLength(1);
55
+ });
56
+ });
@@ -0,0 +1,47 @@
1
+ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
2
+ import type { ReactNode } from 'react';
3
+ import type { LabMode } from '../state/types';
4
+ import { interstellarTheme } from '../theme/interstellar';
5
+ import { useResolvedMode } from './useSystemMode';
6
+
7
+ /** Props for `<LabRoot>`. */
8
+ export interface LabRootProps {
9
+ children: ReactNode;
10
+ /** Color mode. "auto" (default) follows prefers-color-scheme. */
11
+ mode?: LabMode;
12
+ /** Added after `lk-root` on the same element. */
13
+ className?: string;
14
+ }
15
+
16
+ /** The element every labkit component expects above it: `.lk-root`, which
17
+ * carries the design tokens, the font stack, the box-sizing reset and the
18
+ * element defaults a lab's bare markup is styled by, plus a theme when the
19
+ * app has not already applied one.
20
+ *
21
+ * `<LabShell>` and `<Lab>` render this themselves. Mount it by hand around a
22
+ * labkit piece used on its own — a bare `<Workspace>` or `<ControlPanel>` —
23
+ * or those components inherit whatever the host page happens to define.
24
+ * Pair it with `import '@weasel-js/labkit/styles.css'`, which is what defines
25
+ * the rules this element scopes. */
26
+ export function LabRoot({ children, mode = 'auto', className }: LabRootProps) {
27
+ const resolved = useResolvedMode(mode);
28
+ const outer = useThemeOptional();
29
+
30
+ // Overlays portal here rather than to the themed wrapper above: `.lk-root`
31
+ // carries the element defaults a lab's bare markup is styled by.
32
+ const root = (
33
+ <div className={className ? `lk-root ${className}` : 'lk-root'} data-wzl-portal-host="">
34
+ {children}
35
+ </div>
36
+ );
37
+
38
+ // Inside <Lab> the theme is already applied on `.lk-lab`; wrapping again
39
+ // would only add a div.
40
+ return outer ? (
41
+ root
42
+ ) : (
43
+ <ThemeProvider theme={interstellarTheme} selection={{ mode: resolved }}>
44
+ {root}
45
+ </ThemeProvider>
46
+ );
47
+ }