@weasel-js/labkit 1.4.4 → 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 (278) hide show
  1. package/README.md +54 -6
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.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-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
  7. package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
  8. package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
  9. package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
  10. package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
  11. package/dist/canvas/index.d.ts +18 -129
  12. package/dist/canvas/index.js +4 -2
  13. package/dist/chrome/index.d.ts +5 -93
  14. package/dist/chrome/index.js +10 -6
  15. package/dist/chunk-7PFH3CNM.js +150 -0
  16. package/dist/chunk-7PFH3CNM.js.map +1 -0
  17. package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
  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-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
  22. package/dist/chunk-EHGVROVV.js.map +1 -0
  23. package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
  24. package/dist/chunk-ERFMC6WU.js.map +1 -0
  25. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  26. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  27. package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
  28. package/dist/chunk-IHUVQ3TX.js.map +1 -0
  29. package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
  30. package/dist/chunk-IJJUK4FU.js.map +1 -0
  31. package/dist/chunk-IXTQI7JX.js +737 -0
  32. package/dist/chunk-IXTQI7JX.js.map +1 -0
  33. package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
  34. package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
  35. package/dist/chunk-PHK4W562.js +72 -0
  36. package/dist/chunk-PHK4W562.js.map +1 -0
  37. package/dist/chunk-PJ7WEQBJ.js +18 -0
  38. package/dist/chunk-PJ7WEQBJ.js.map +1 -0
  39. package/dist/chunk-RWNDDKOH.js +682 -0
  40. package/dist/chunk-RWNDDKOH.js.map +1 -0
  41. package/dist/chunk-T3EC2DXD.js +12628 -0
  42. package/dist/chunk-T3EC2DXD.js.map +1 -0
  43. package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
  44. package/dist/chunk-UN4U4P63.js.map +1 -0
  45. package/dist/chunk-WIG6XHJ7.js +35 -0
  46. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  47. package/dist/chunk-Z76KEZAX.js +15 -0
  48. package/dist/chunk-Z76KEZAX.js.map +1 -0
  49. package/dist/chunk-ZDHY47EV.js +363 -0
  50. package/dist/chunk-ZDHY47EV.js.map +1 -0
  51. package/dist/config/index.d.ts +276 -0
  52. package/dist/config/index.js +8 -0
  53. package/dist/config/index.js.map +1 -0
  54. package/dist/controls/index.d.ts +3 -4
  55. package/dist/controls/index.js +4 -3
  56. package/dist/dragdrop/index.d.ts +3 -5
  57. package/dist/index.d.ts +266 -413
  58. package/dist/index.js +1775 -2251
  59. package/dist/index.js.map +1 -1
  60. package/dist/layers/index.d.ts +4 -6
  61. package/dist/layers/index.js +2 -2
  62. package/dist/loupe/index.d.ts +69 -20
  63. package/dist/loupe/index.js +2 -2
  64. package/dist/passthrough/weasel-ui.d.ts +175 -309
  65. package/dist/passthrough/weasel-ui.js +1 -1
  66. package/dist/primitives/index.d.ts +55 -6
  67. package/dist/primitives/index.js +6 -4
  68. package/dist/state/index.d.ts +13 -8
  69. package/dist/state/index.js +18 -13
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/styles.css +223 -76
  72. package/dist/surface/index.d.ts +44 -7
  73. package/dist/surface/index.js +2 -1
  74. package/dist/ui/layers/index.js +1 -1
  75. package/dist/undo/index.d.ts +3 -5
  76. package/package.json +15 -8
  77. package/src/annotations/AnnotationOverlay.tsx +56 -5
  78. package/src/annotations/Annotations.less +4 -0
  79. package/src/annotations/Annotations.overlay.test.tsx +105 -6
  80. package/src/annotations/drawOne.test.ts +1 -1
  81. package/src/annotations/drawOne.ts +2 -2
  82. package/src/annotations/paint.test.ts +2 -2
  83. package/src/annotations/paint.ts +1 -1
  84. package/src/annotations/preload.ts +6 -0
  85. package/src/annotations/store.test.ts +18 -0
  86. package/src/annotations/store.ts +5 -4
  87. package/src/annotations/svgNodes.test.ts +2 -2
  88. package/src/annotations/svgNodes.ts +1 -0
  89. package/src/annotations/toolMap.test.ts +9 -5
  90. package/src/annotations/toolMap.ts +6 -3
  91. package/src/annotations/types.ts +6 -3
  92. package/src/canvas/CameraWheelContext.ts +12 -0
  93. package/src/canvas/CanvasStack.stories.tsx +1 -1
  94. package/src/canvas/CanvasStack.test.tsx +25 -0
  95. package/src/canvas/CanvasStack.tsx +31 -4
  96. package/src/canvas/Stage.less +18 -0
  97. package/src/canvas/Stage.test.tsx +186 -0
  98. package/src/canvas/Stage.tsx +134 -0
  99. package/src/canvas/camera.test.ts +17 -2
  100. package/src/canvas/camera.ts +9 -9
  101. package/src/canvas/index.ts +4 -0
  102. package/src/canvas/usePanZoom.test.ts +28 -0
  103. package/src/canvas/usePanZoom.ts +21 -9
  104. package/src/chrome/ChromeRegions.stories.tsx +1 -4
  105. package/src/chrome/LabChrome.tsx +130 -0
  106. package/src/chrome/builtins.test.ts +1 -0
  107. package/src/chrome/builtins.tsx +21 -31
  108. package/src/chrome/index.ts +16 -0
  109. package/src/chrome/labRegions.test.tsx +208 -0
  110. package/src/chrome/labTypes.ts +50 -0
  111. package/src/chrome/merge.ts +7 -9
  112. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  113. package/src/chrome/regions/PaletteRegion.tsx +16 -9
  114. package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
  115. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  116. package/src/chrome/regions/StatusRegion.tsx +15 -8
  117. package/src/chrome/regions/ToolbarRegion.tsx +34 -16
  118. package/src/chrome/types.ts +50 -20
  119. package/src/config/auto.test.ts +12 -0
  120. package/src/config/auto.ts +15 -0
  121. package/src/config/autoConfig.test.ts +107 -0
  122. package/src/config/autoConfig.ts +153 -0
  123. package/src/config/builder.test.ts +42 -0
  124. package/src/config/builder.ts +68 -17
  125. package/src/config/declarationEmit.fixture.ts +11 -0
  126. package/src/config/declarationEmit.test.ts +51 -0
  127. package/src/config/entry.test.ts +46 -0
  128. package/src/config/index.ts +16 -1
  129. package/src/config/nodeClasses.test.ts +18 -0
  130. package/src/config/path.ts +7 -2
  131. package/src/config/resolve.test.ts +25 -0
  132. package/src/config/resolve.ts +17 -1
  133. package/src/config/types.ts +34 -2
  134. package/src/config/useResolvedConfig.ts +26 -0
  135. package/src/controls/AutoControls.stories.tsx +88 -0
  136. package/src/controls/ControlPanel.stories.tsx +42 -1
  137. package/src/controls/ControlPanel.test.tsx +530 -4
  138. package/src/controls/ControlPanel.tsx +562 -82
  139. package/src/fake-indexeddb-auto.d.ts +5 -0
  140. package/src/index.test.ts +7 -0
  141. package/src/index.ts +66 -5
  142. package/src/instrument/serializers.test.ts +22 -0
  143. package/src/instrument/serializers.ts +40 -1
  144. package/src/instrument/types.ts +25 -1
  145. package/src/lab/Lab.chrome.test.tsx +16 -0
  146. package/src/lab/Lab.less +4 -13
  147. package/src/lab/Lab.persist.test.tsx +147 -0
  148. package/src/lab/Lab.stories.tsx +1 -3
  149. package/src/lab/Lab.surface.test.tsx +71 -3
  150. package/src/lab/Lab.test.tsx +168 -16
  151. package/src/lab/Lab.tsx +434 -129
  152. package/src/lab/LabContext.ts +8 -0
  153. package/src/lab/LabFit.stories.less +18 -0
  154. package/src/lab/LabFit.stories.tsx +219 -0
  155. package/src/lab/LabFullChrome.stories.tsx +1 -4
  156. package/src/lab/LabHeader.test.tsx +40 -9
  157. package/src/lab/LabHeader.tsx +7 -14
  158. package/src/lab/LabPalette.tsx +13 -26
  159. package/src/lab/LabRoot.test.tsx +56 -0
  160. package/src/lab/LabRoot.tsx +47 -0
  161. package/src/lab/LabShell.less +73 -15
  162. package/src/lab/LabShell.stories.tsx +50 -1
  163. package/src/lab/LabShell.tsx +18 -27
  164. package/src/lab/LabSwitcher.less +18 -11
  165. package/src/lab/LabSwitcher.test.tsx +14 -9
  166. package/src/lab/LabSwitcher.tsx +8 -4
  167. package/src/lab/Workspace.less +1 -1
  168. package/src/lab/Workspace.stories.tsx +1 -1
  169. package/src/lab/Workspace.surface.test.tsx +1 -0
  170. package/src/lab/fitCheck.test.ts +57 -0
  171. package/src/lab/fitCheck.ts +125 -0
  172. package/src/lab/index.ts +2 -0
  173. package/src/layers/LayerList.stories.tsx +1 -1
  174. package/src/loupe/AGENTS.md +17 -5
  175. package/src/loupe/Loupe.less +1 -1
  176. package/src/loupe/LoupeGestures.tsx +67 -0
  177. package/src/loupe/TrialLoupe.tsx +43 -28
  178. package/src/loupe/canvasLens.ts +1 -1
  179. package/src/loupe/index.ts +4 -0
  180. package/src/loupe/loupeActions.ts +98 -0
  181. package/src/loupe/types.ts +1 -1
  182. package/src/loupe/useLoupe.test.tsx +60 -3
  183. package/src/loupe/useLoupe.ts +29 -49
  184. package/src/passthrough/weasel-ui.ts +9 -0
  185. package/src/primitives/FloatingPanel.stories.tsx +14 -2
  186. package/src/primitives/FloatingPanel.test.tsx +47 -16
  187. package/src/primitives/FloatingPanel.tsx +11 -25
  188. package/src/primitives/FpsMeter.stories.tsx +1 -1
  189. package/src/primitives/JobProgress.stories.tsx +1 -1
  190. package/src/primitives/Legend.stories.tsx +1 -1
  191. package/src/primitives/ScaleIndicator.stories.tsx +1 -1
  192. package/src/primitives/Sidebar.less +7 -0
  193. package/src/primitives/Sidebar.stories.tsx +1 -1
  194. package/src/primitives/Split.less +14 -0
  195. package/src/primitives/Split.test.tsx +133 -0
  196. package/src/primitives/Split.tsx +175 -0
  197. package/src/primitives/StatusBar.stories.tsx +1 -1
  198. package/src/primitives/Toolbar.stories.tsx +1 -1
  199. package/src/primitives/ZoomControl.tsx +5 -2
  200. package/src/primitives/index.ts +2 -0
  201. package/src/specimen/Specimen.less +95 -0
  202. package/src/specimen/Specimen.stories.tsx +13 -0
  203. package/src/specimen/Specimen.test.tsx +13 -0
  204. package/src/specimen/Specimen.tsx +888 -0
  205. package/src/state/Persistence.stories.tsx +75 -0
  206. package/src/state/Persistence.tsx +33 -0
  207. package/src/state/SingletonExperiment.test.tsx +47 -53
  208. package/src/state/SingletonExperiment.tsx +24 -13
  209. package/src/state/adapterContract.ts +116 -0
  210. package/src/state/adapters.test.ts +113 -61
  211. package/src/state/adapters.ts +328 -69
  212. package/src/state/document.test.ts +71 -96
  213. package/src/state/document.ts +66 -52
  214. package/src/state/helpers.test.ts +1 -1
  215. package/src/state/index.ts +8 -0
  216. package/src/state/labRecords.test.ts +105 -0
  217. package/src/state/labRecords.ts +160 -0
  218. package/src/state/openLabStore.test.ts +658 -0
  219. package/src/state/openLabStore.ts +403 -0
  220. package/src/state/records.test.ts +234 -0
  221. package/src/state/records.ts +219 -0
  222. package/src/state/store.test.ts +169 -635
  223. package/src/state/store.ts +146 -163
  224. package/src/state/toolSlot.test.ts +1 -1
  225. package/src/state/types.ts +65 -17
  226. package/src/state/useOpenOnce.ts +68 -0
  227. package/src/state/usePersistedState.test.tsx +125 -0
  228. package/src/state/usePersistedState.ts +66 -0
  229. package/src/state/useTrialState.test.tsx +87 -3
  230. package/src/state/useTrialState.ts +9 -1
  231. package/src/state/view.test.ts +49 -15
  232. package/src/state/view.ts +18 -1
  233. package/src/styles.less +3 -0
  234. package/src/surface/AGENTS.md +39 -7
  235. package/src/surface/SurfaceContext.ts +26 -3
  236. package/src/surface/index.ts +2 -0
  237. package/src/surface/useSurfaceTile.test.tsx +1 -0
  238. package/src/surface/useSurfaceTile.ts +6 -5
  239. package/src/surface/useTiledSurface.test.tsx +231 -1
  240. package/src/surface/useTiledSurface.ts +76 -15
  241. package/src/theme/Interstellar.stories.tsx +9 -9
  242. package/src/theme/interstellar.test.ts +17 -6
  243. package/src/theme/interstellar.theme.json +180 -0
  244. package/src/theme/interstellar.ts +8 -5
  245. package/src/tools/labTool.ts +11 -0
  246. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  247. package/src/trial/Trial.annotations.test.tsx +59 -7
  248. package/src/trial/Trial.canvas.test.tsx +32 -0
  249. package/src/trial/Trial.config.test.tsx +75 -2
  250. package/src/trial/Trial.job.test.tsx +3 -3
  251. package/src/trial/Trial.less +5 -26
  252. package/src/trial/Trial.stories.tsx +5 -3
  253. package/src/trial/Trial.test.tsx +31 -4
  254. package/src/trial/Trial.trialId.test.tsx +23 -1
  255. package/src/trial/Trial.tsx +139 -52
  256. package/src/trial/TrialBody.tsx +17 -141
  257. package/src/trial/TrialChrome.tsx +24 -7
  258. package/src/trial/trialOps.test.ts +101 -1
  259. package/src/trial/trialOps.ts +67 -7
  260. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  261. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  262. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  263. package/dist/chunk-2P6PP5N4.js +0 -553
  264. package/dist/chunk-2P6PP5N4.js.map +0 -1
  265. package/dist/chunk-64ZCN3DA.js.map +0 -1
  266. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  267. package/dist/chunk-E44SU6XS.js +0 -50
  268. package/dist/chunk-E44SU6XS.js.map +0 -1
  269. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  270. package/dist/chunk-I6JCVE24.js.map +0 -1
  271. package/dist/chunk-MOM3GOVY.js.map +0 -1
  272. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  273. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  274. package/dist/chunk-U3IYHIAE.js +0 -343
  275. package/dist/chunk-U3IYHIAE.js.map +0 -1
  276. package/dist/chunk-W3ECWC2K.js +0 -7335
  277. package/dist/chunk-W3ECWC2K.js.map +0 -1
  278. package/src/theme/interstellar.tokens.json +0 -61
@@ -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;
@@ -0,0 +1,18 @@
1
+ // Host pages for LabFit.stories. Each is scoped by its own class, so a story
2
+ // that mounts no fixture leaves the page untouched.
3
+
4
+ // The mount every example documents: `html, body, #root { margin: 0; height: 100% }`.
5
+ html:has(> body > .lk-fit-host--reset),
6
+ body:has(> .lk-fit-host--reset),
7
+ .lk-fit-host--reset {
8
+ margin: 0;
9
+ height: 100%;
10
+ }
11
+
12
+ .lk-fit-heading {
13
+ height: 40px;
14
+ }
15
+
16
+ .lk-fit-frame {
17
+ height: 560px;
18
+ }
@@ -0,0 +1,219 @@
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { expect, userEvent, waitFor, within } from '@weasel-js/forge/play';
3
+ import { type ReactNode, useLayoutEffect, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import type { LabContribution } from '../chrome/labTypes';
6
+ import type { TrialContribution } from '../chrome/types';
7
+ import type { Instrument } from '../instrument/types';
8
+ import { Lab } from './Lab';
9
+ import './LabFit.stories.less';
10
+
11
+ // A lab must fit whatever it is mounted in without scrolling. jsdom cannot see
12
+ // layout, so these run in the forge-stories vitest project's real browser.
13
+
14
+ const Stub: Instrument = {
15
+ name: 'Stub',
16
+ defaultConfig: () => ({}),
17
+ initialState: () => ({}),
18
+ render: () => <div>stub</div>,
19
+ };
20
+
21
+ const floater: TrialContribution = {
22
+ id: 'floater',
23
+ region: 'sidebar',
24
+ item: { title: 'Floater', undockAs: 'floating', body: <p>floating panel</p> },
25
+ };
26
+
27
+ const sidebarTree: LabContribution = {
28
+ id: 'tree',
29
+ region: 'sidebar',
30
+ item: {
31
+ title: 'Stories',
32
+ body: Array.from({ length: 40 }, (_, i) => `Story ${i + 1}`).map((name) => (
33
+ <p key={name}>{name}</p>
34
+ )),
35
+ },
36
+ };
37
+
38
+ const VIEWPORTS = {
39
+ wide: { name: '1280x800', styles: { width: '1280px', height: '800px' } },
40
+ medium: { name: '800x600', styles: { width: '800px', height: '600px' } },
41
+ narrow: { name: '400x700', styles: { width: '400px', height: '700px' } },
42
+ };
43
+
44
+ type Host = 'reset' | 'wrapped' | 'framed';
45
+
46
+ // The frame wraps every story in divs of its own, so a fixture that needs a
47
+ // known ancestor chain builds that chain directly on <body> and portals into it.
48
+ function BodyHost({ host, children }: { host: Host; children: ReactNode }) {
49
+ const [mount, setMount] = useState<HTMLElement | null>(null);
50
+ useLayoutEffect(() => {
51
+ const outer = document.createElement('div');
52
+ outer.className = `lk-fit-host lk-fit-host--${host}`;
53
+ let target = outer;
54
+ if (host === 'wrapped') {
55
+ target = document.createElement('div');
56
+ outer.appendChild(target);
57
+ }
58
+ if (host === 'framed') {
59
+ // apps/site's shape: page content above a fixed-height frame.
60
+ const heading = document.createElement('div');
61
+ heading.className = 'lk-fit-heading';
62
+ target = document.createElement('div');
63
+ target.className = 'lk-fit-frame';
64
+ outer.append(heading, target);
65
+ }
66
+ document.body.appendChild(outer);
67
+ setMount(target);
68
+ return () => outer.remove();
69
+ }, [host]);
70
+ return mount ? createPortal(children, mount) : null;
71
+ }
72
+
73
+ type Fixture = 'fullscreen' | 'floating' | 'wrapped' | 'framed' | 'sidebar';
74
+
75
+ const HOST: Record<Fixture, Host> = {
76
+ fullscreen: 'reset',
77
+ sidebar: 'reset',
78
+ floating: 'reset',
79
+ wrapped: 'wrapped',
80
+ framed: 'framed',
81
+ };
82
+
83
+ function scrolls(el: Element): string[] {
84
+ const out: string[] = [];
85
+ if (el.scrollHeight > el.clientHeight)
86
+ out.push(`scrollHeight ${el.scrollHeight} > ${el.clientHeight}`);
87
+ if (el.scrollWidth > el.clientWidth)
88
+ out.push(`scrollWidth ${el.scrollWidth} > ${el.clientWidth}`);
89
+ return out;
90
+ }
91
+
92
+ function fitProblems(fixture: Fixture, viewport: keyof typeof VIEWPORTS): string[] {
93
+ const problems: string[] = [];
94
+ const want = VIEWPORTS[viewport].styles;
95
+ const got = `${window.innerWidth}px x ${window.innerHeight}px`;
96
+ if (got !== `${want.width} x ${want.height}`) problems.push(`viewport is ${got}`);
97
+
98
+ // A box that spills out of the lab is overflow even where the page has room
99
+ // to absorb it, which is how a 100vh shell hid inside an embedded lab.
100
+ const labBox = document.querySelector('.lk-lab')?.getBoundingClientRect();
101
+ const shellBox = document.querySelector('.lk-shell')?.getBoundingClientRect();
102
+ if (!labBox || !shellBox) return [...problems, 'no .lk-lab or .lk-shell'];
103
+ if (shellBox.bottom > labBox.bottom + 1 || shellBox.right > labBox.right + 1) {
104
+ problems.push(
105
+ `.lk-shell ${shellBox.bottom}/${shellBox.right} spills out of .lk-lab ${labBox.bottom}/${labBox.right}`,
106
+ );
107
+ }
108
+
109
+ const doc = document.scrollingElement ?? document.documentElement;
110
+ for (const p of scrolls(doc)) problems.push(`document: ${p}`);
111
+
112
+ const body = document.querySelector('.lk-shell-body');
113
+ if (!body) return ['no .lk-shell-body'];
114
+ for (const p of scrolls(body)) problems.push(`.lk-shell-body: ${p}`);
115
+
116
+ const tiles = [...document.querySelectorAll('.lk-trial-tile')];
117
+ if (tiles.length === 0) problems.push('no .lk-trial-tile');
118
+ tiles.forEach((t, i) => {
119
+ const h = t.getBoundingClientRect().height;
120
+ if (!(h > 0)) problems.push(`tile ${i}: height ${h}`);
121
+ });
122
+
123
+ if (fixture === 'framed') {
124
+ const frame = document.querySelector('.lk-fit-frame');
125
+ const lab = document.querySelector('.lk-lab');
126
+ const f = frame?.getBoundingClientRect();
127
+ const l = lab?.getBoundingClientRect();
128
+ if (!f || !l) return [...problems, 'no frame or lab'];
129
+ if (Math.abs(l.height - f.height) > 1)
130
+ problems.push(`lab height ${l.height} != frame ${f.height}`);
131
+ }
132
+
133
+ if (fixture === 'floating') {
134
+ const panel = document.querySelector('.lk-panel-tile--floating')?.getBoundingClientRect();
135
+ const host = document.querySelector('.lk-workspace-host')?.getBoundingClientRect();
136
+ if (!panel || !host) return [...problems, 'no floating panel or workspace host'];
137
+ if (panel.top < host.top || panel.right > host.right + 1) {
138
+ problems.push(
139
+ `floating panel ${panel.top},${panel.right} outside workspace ${host.top},${host.right}`,
140
+ );
141
+ }
142
+ }
143
+
144
+ if (fixture === 'sidebar') {
145
+ const side = document.querySelector('.lk-lab__sidebar')?.getBoundingClientRect();
146
+ // The host wraps the grid only once a panel is undocked.
147
+ const work = document
148
+ .querySelector('.lk-lab__main > .lk-workspace-host, .lk-lab__main > .lk-workspace')
149
+ ?.getBoundingClientRect();
150
+ if (!side) return [...problems, 'no .lk-lab__sidebar'];
151
+ if (!work) return [...problems, 'no workspace in .lk-lab__main'];
152
+ if (!(side.width > 0)) problems.push(`lab sidebar width ${side.width}`);
153
+ if (!(work.width > 0)) problems.push(`workspace width ${work.width}`);
154
+ if (work.left < side.right - 1) {
155
+ problems.push(`workspace left ${work.left} overlaps sidebar right ${side.right}`);
156
+ }
157
+ }
158
+ return problems;
159
+ }
160
+
161
+ function fit(fixture: Fixture, viewport: keyof typeof VIEWPORTS): Story {
162
+ return {
163
+ globals: { viewport: { value: viewport, isRotated: false } },
164
+ render: () => (
165
+ <BodyHost host={HOST[fixture]}>
166
+ <Lab
167
+ title={`Fit: ${fixture}`}
168
+ instruments={[Stub]}
169
+ defaultInstrument="Stub"
170
+ chrome={fixture === 'floating' ? [floater] : undefined}
171
+ labChrome={fixture === 'sidebar' ? [sidebarTree] : undefined}
172
+ />
173
+ </BodyHost>
174
+ ),
175
+ play: async () => {
176
+ if (fixture === 'floating') {
177
+ const undock = await within(document.body).findByRole('button', { name: 'Undock Floater' });
178
+ await userEvent.click(undock);
179
+ }
180
+ await waitFor(
181
+ () => {
182
+ const problems = fitProblems(fixture, viewport);
183
+ if (problems.length > 0) throw new Error(problems.join('; '));
184
+ },
185
+ { timeout: 2000 },
186
+ );
187
+ await expect(fitProblems(fixture, viewport)).toEqual([]);
188
+ },
189
+ };
190
+ }
191
+
192
+ const meta: Meta<typeof Lab> = {
193
+ title: 'labkit/Lab/Fit',
194
+ component: Lab,
195
+ parameters: { layout: 'fullscreen', viewport: { options: VIEWPORTS } },
196
+ };
197
+ export default meta;
198
+
199
+ type Story = StoryObj<typeof Lab>;
200
+
201
+ export const FullscreenWide = fit('fullscreen', 'wide');
202
+ export const FullscreenMedium = fit('fullscreen', 'medium');
203
+ export const FullscreenNarrow = fit('fullscreen', 'narrow');
204
+
205
+ export const FloatingPanelWide = fit('floating', 'wide');
206
+ export const FloatingPanelMedium = fit('floating', 'medium');
207
+ export const FloatingPanelNarrow = fit('floating', 'narrow');
208
+
209
+ export const WrappedNoResetWide = fit('wrapped', 'wide');
210
+ export const WrappedNoResetMedium = fit('wrapped', 'medium');
211
+ export const WrappedNoResetNarrow = fit('wrapped', 'narrow');
212
+
213
+ export const FramedWide = fit('framed', 'wide');
214
+ export const FramedMedium = fit('framed', 'medium');
215
+ export const FramedNarrow = fit('framed', 'narrow');
216
+
217
+ export const SidebarWide = fit('sidebar', 'wide');
218
+ export const SidebarMedium = fit('sidebar', 'medium');
219
+ export const SidebarNarrow = fit('sidebar', 'narrow');
@@ -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';
@@ -268,7 +268,6 @@ export const AllChrome: Story = {
268
268
  instruments: [spectrometer, beamProfile, thermalDrift],
269
269
  defaultInstrument: 'Spectrometer',
270
270
  title: 'Optics bench',
271
- storage: null,
272
271
  mode: 'dark',
273
272
  nebula: ['#3b5bdb', '#7048e8', '#0ca678'],
274
273
  children: (
@@ -309,7 +308,6 @@ export const SingleInstrument: Story = {
309
308
  instruments: [runLog],
310
309
  defaultInstrument: 'Run log',
311
310
  title: 'Run log',
312
- storage: null,
313
311
  },
314
312
  };
315
313
 
@@ -321,7 +319,6 @@ export const WithFooter: Story = {
321
319
  instruments: [spectrometer, beamProfile],
322
320
  defaultInstrument: 'Spectrometer',
323
321
  title: 'With footer',
324
- storage: null,
325
322
  mode: 'dark',
326
323
  footer: <span className="lk-run-tag">Footer — reached through LabProps.footer</span>,
327
324
  },
@@ -13,28 +13,59 @@ const Other: Instrument = { ...Stub, name: 'Other' };
13
13
 
14
14
  describe('<LabHeader>', () => {
15
15
  it('offers an add-trial button when the lab has one instrument', () => {
16
- render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
16
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
17
17
  expect(screen.getByRole('button', { name: /add trial/i })).toBeInTheDocument();
18
18
  });
19
19
 
20
- it('offers a picker instead when the lab has several', () => {
21
- render(<Lab instruments={[Stub, Other]} defaultInstrument="Stub" storage={null} />);
22
- // A picker, not a button — which instrument to add is now a choice.
23
- expect(screen.queryByRole('button', { name: /^add trial$/i })).toBeNull();
24
- expect(screen.getByLabelText(/add trial/i)).toBeInTheDocument();
20
+ it('offers a menu instead when the lab has several', () => {
21
+ render(<Lab instruments={[Stub, Other]} defaultInstrument="Stub" />);
22
+ // Which instrument to add is now a choice, so the button opens a list.
23
+ expect(screen.getByRole('button', { name: 'Add trial' })).toHaveAttribute('aria-haspopup');
24
+ });
25
+
26
+ it('adds a trial of the instrument chosen from the menu', async () => {
27
+ const { default: userEvent } = await import('@testing-library/user-event');
28
+ const user = userEvent.setup();
29
+ render(<Lab instruments={[Stub, Other]} defaultInstrument="Stub" />);
30
+ expect(screen.queryAllByLabelText(/^Trial Other$/)).toHaveLength(0);
31
+ await user.click(screen.getByRole('button', { name: 'Add trial' }));
32
+ await user.click(screen.getByRole('menuitem', { name: 'Other' }));
33
+ expect(screen.getAllByLabelText(/^Trial Other$/)).toHaveLength(1);
34
+ });
35
+
36
+ it('lists each instrument by its title where it has one', async () => {
37
+ const { default: userEvent } = await import('@testing-library/user-event');
38
+ const user = userEvent.setup();
39
+ const titled: Instrument = { ...Other, title: 'Other / Titled' };
40
+ render(<Lab instruments={[Stub, titled]} defaultInstrument="Stub" />);
41
+ await user.click(screen.getByRole('button', { name: 'Add trial' }));
42
+ expect(screen.getByRole('menuitem', { name: 'Stub' })).toBeInTheDocument();
43
+ await user.click(screen.getByRole('menuitem', { name: 'Other / Titled' }));
44
+ expect(screen.getAllByLabelText(/^Trial Other \/ Titled$/)).toHaveLength(1);
25
45
  });
26
46
 
27
47
  it('adds a trial when the button is used', async () => {
28
48
  const { default: userEvent } = await import('@testing-library/user-event');
29
49
  const user = userEvent.setup();
30
- render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
50
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
31
51
  expect(screen.getAllByLabelText(/^Trial Stub$/)).toHaveLength(1);
32
52
  await user.click(screen.getByRole('button', { name: /add trial/i }));
33
53
  expect(screen.getAllByLabelText(/^Trial Stub$/)).toHaveLength(2);
34
54
  });
35
55
 
56
+ it('leaves the add-trial control out when the lab says so', () => {
57
+ const { unmount } = render(
58
+ <Lab instruments={[Stub, Other]} defaultInstrument="Stub" addTrial={false} />,
59
+ );
60
+ expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
61
+ expect(screen.getByRole('radio', { name: 'Auto' })).toBeInTheDocument();
62
+ unmount();
63
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" addTrial={false} />);
64
+ expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
65
+ });
66
+
36
67
  it('exposes the color mode as a three-way choice', () => {
37
- render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
68
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
38
69
  for (const label of ['Auto', 'Light', 'Dark']) {
39
70
  expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
40
71
  }
@@ -44,7 +75,7 @@ describe('<LabHeader>', () => {
44
75
  // The assertion above passes whether the segment holds text or an icon,
45
76
  // because both routes produce the same accessible name. This is the one
46
77
  // that can tell them apart.
47
- render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
78
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
48
79
  for (const label of ['Auto', 'Light', 'Dark']) {
49
80
  const segment = screen.getByRole('radio', { name: label });
50
81
  expect(segment.querySelector('svg')).not.toBeNull();
@@ -1,5 +1,5 @@
1
1
  import { AddIcon, ModeAutoIcon, ModeDarkIcon, ModeLightIcon } from '@weasel-js/ui';
2
- import { Select, ToggleBar } from '../passthrough/weasel-ui';
2
+ import { MenuButton, ToggleBar } from '../passthrough/weasel-ui';
3
3
  import type { LabMode } from '../state/types';
4
4
  import { useLabContext } from './LabContext';
5
5
 
@@ -21,13 +21,13 @@ const MODE_ITEMS = MODES.map(({ value, label, glyph }) => ({
21
21
  * mode. Both drive `LabContext`, which carried them with no UI at all — so
22
22
  * every consumer rebuilt these two. Rendered before a consumer's own header
23
23
  * content, which still lands beside them. */
24
- export function LabHeader() {
24
+ export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
25
25
  const lab = useLabContext();
26
26
  const only = lab.instruments.length === 1 ? lab.instruments[0] : null;
27
27
 
28
28
  return (
29
29
  <>
30
- {only ? (
30
+ {!addTrial ? null : only ? (
31
31
  <button
32
32
  type="button"
33
33
  className="lk-lab-header__add"
@@ -37,24 +37,17 @@ export function LabHeader() {
37
37
  <span>Add trial</span>
38
38
  </button>
39
39
  ) : (
40
- // Held at null so the control stays an "add one" action rather than
41
- // reading as the current instrument.
42
- <Select
43
- className="lk-lab-header__add-select"
40
+ <MenuButton
44
41
  aria-label="Add trial"
45
- placeholder="Add trial…"
46
- selectedKey={null}
47
- options={lab.instruments.map((i) => ({ value: i.name, label: i.name }))}
48
- onSelectionChange={(name) => {
49
- if (name != null) lab.addTrial(String(name));
50
- }}
42
+ label="Add trial…"
43
+ items={lab.instruments.map((i) => ({ value: i.name, label: i.title ?? i.name }))}
44
+ onAction={(name) => lab.addTrial(name)}
51
45
  />
52
46
  )}
53
47
 
54
48
  <ToggleBar
55
49
  className="lk-lab-header__mode"
56
50
  ariaLabel="Color mode"
57
- size="sm"
58
51
  variant="flat"
59
52
  items={MODE_ITEMS}
60
53
  value={lab.mode}
@@ -1,34 +1,21 @@
1
- import { useContext } from 'react';
2
- import { useStore } from 'zustand/react';
1
+ import { labContributions, useLabChromeContext } from '../chrome/LabChrome';
2
+ import type { LabContribution } from '../chrome/labTypes';
3
3
  import { PaletteRegion } from '../chrome/regions/PaletteRegion';
4
- import type { TrialChromeContext, TrialContribution } from '../chrome/types';
5
- import { LabStoreContext } from '../state/context';
6
4
  import type { TrialTool } from '../tools/types';
7
5
 
8
6
  /** Props for `<LabPalette>`. */
9
7
  export interface LabPaletteProps {
10
- tools: readonly TrialTool[];
8
+ /** Tools offered lab-wide. Shorthand for one `palette` contribution each. */
9
+ tools?: readonly TrialTool[];
10
+ /** The lab's contributions; the `palette` ones are laid out here. */
11
+ contributions?: readonly LabContribution[];
11
12
  }
12
13
 
13
- /** The lab's tool strip. Writes the lab's tool slot, which every trial whose
14
- * instrument declares no tools of its own resolves to. */
15
- export function LabPalette({ tools }: LabPaletteProps) {
16
- const storeCtx = useContext(LabStoreContext);
17
- if (!storeCtx) throw new Error('[labkit] LabPalette requires <LabStoreProvider>');
18
- const activeToolId = useStore(storeCtx.store, (s) => s.activeToolId);
19
- const setLabTool = useStore(storeCtx.store, (s) => s.setLabTool);
20
-
21
- const contributions: TrialContribution[] = tools.map((t) => ({
22
- id: t.id,
23
- region: 'palette',
24
- group: t.group,
25
- item: { icon: t.icon, label: t.label, shortcut: t.shortcut },
26
- }));
27
-
28
- const ctx = {
29
- activeToolId,
30
- setActiveTool: setLabTool,
31
- } as unknown as TrialChromeContext;
32
-
33
- return <PaletteRegion contributions={contributions} ctx={ctx} />;
14
+ /** The lab's tool rail. Writes the lab's tool slot, which every trial whose
15
+ * instrument declares no tools of its own resolves to. Renders nothing when
16
+ * the lab contributes no tool rail. */
17
+ export function LabPalette({ tools, contributions }: LabPaletteProps) {
18
+ const ctx = useLabChromeContext();
19
+ const all = labContributions(tools, contributions);
20
+ return <PaletteRegion contributions={all.filter((c) => c.region === 'palette')} ctx={ctx} />;
34
21
  }
@@ -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
+ }