@weasel-js/labkit 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-DiuEArKq.d.ts} +4 -4
  4. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  5. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  6. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Bensr_MT.d.ts} +15 -6
  7. package/dist/_dts/Stage-CCWfQQWM.d.ts +202 -0
  8. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  9. package/dist/_dts/{index-B0--8PEM.d.ts → index-CsYqUHD3.d.ts} +12 -13
  10. package/dist/_dts/{types-B7y8hX1D.d.ts → types-CRPSGFAp.d.ts} +33 -5
  11. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-CSm9jgol.d.ts} +700 -650
  12. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CTZPgeZ7.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +40 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +5 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-275JRRVD.js +728 -0
  20. package/dist/chunk-275JRRVD.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/chunk-4LJSC7MB.js +143 -0
  24. package/dist/chunk-4LJSC7MB.js.map +1 -0
  25. package/dist/{chunk-3FI7Y4R4.js → chunk-7BO7HNGF.js} +5 -5
  26. package/dist/{chunk-3FI7Y4R4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  27. package/dist/chunk-AWQG7FCC.js +19 -0
  28. package/dist/chunk-AWQG7FCC.js.map +1 -0
  29. package/dist/chunk-CZZPXUOU.js +3 -0
  30. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  31. package/dist/{chunk-ZORVNOKX.js → chunk-FMK7UQ6V.js} +24 -145
  32. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  33. package/dist/{chunk-BVL7Y574.js → chunk-FZ7YFQN7.js} +26 -10
  34. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  35. package/dist/{chunk-USO6TERP.js → chunk-GOZLNM4L.js} +11 -9
  36. package/dist/chunk-GOZLNM4L.js.map +1 -0
  37. package/dist/{chunk-H5VIV7MQ.js → chunk-I5DUQ5HM.js} +88 -82
  38. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  39. package/dist/chunk-J7OWWTCW.js +143 -0
  40. package/dist/chunk-J7OWWTCW.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/{chunk-VQ47KWD5.js → chunk-KMJ7RWD3.js} +18 -7
  44. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  45. package/dist/{chunk-ERFMC6WU.js → chunk-M7HP3FTK.js} +20 -11
  46. package/dist/chunk-M7HP3FTK.js.map +1 -0
  47. package/dist/chunk-MIM5R4ZT.js +14972 -0
  48. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  49. package/dist/{chunk-HFRE4QQV.js → chunk-T5IBSPAM.js} +96 -32
  50. package/dist/chunk-T5IBSPAM.js.map +1 -0
  51. package/dist/chunk-UNAZSLWE.js +294 -0
  52. package/dist/chunk-UNAZSLWE.js.map +1 -0
  53. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  54. package/dist/chunk-WPLAANH4.js.map +1 -0
  55. package/dist/{chunk-AWH3SRPW.js → chunk-Y23G2VZL.js} +5 -37
  56. package/dist/chunk-Y23G2VZL.js.map +1 -0
  57. package/dist/{chunk-MOLIUNVH.js → chunk-YT75VYRD.js} +4 -4
  58. package/dist/{chunk-MOLIUNVH.js.map → chunk-YT75VYRD.js.map} +1 -1
  59. package/dist/chunk-ZIFPHIWZ.js +302 -0
  60. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  61. package/dist/config/index.d.ts +48 -40
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +91 -7
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -7
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +294 -82
  68. package/dist/index.js +425 -402
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +4 -5
  71. package/dist/layers/index.js +2 -1
  72. package/dist/loupe/index.d.ts +20 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +289 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +239 -69
  78. package/dist/passthrough/weasel-ui.js +3 -2
  79. package/dist/primitives/index.d.ts +5 -204
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -8
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +152 -82
  85. package/dist/undo/index.d.ts +4 -4
  86. package/package.json +17 -12
  87. package/src/annotations/AnnotationOverlay.tsx +2 -2
  88. package/src/annotations/Annotations.less +0 -10
  89. package/src/annotations/MarkList.tsx +4 -8
  90. package/src/annotations/index.ts +1 -0
  91. package/src/annotations/paint.test.ts +3 -14
  92. package/src/annotations/paint.ts +3 -19
  93. package/src/annotations/store.test.ts +26 -0
  94. package/src/annotations/store.ts +25 -6
  95. package/src/annotations/svgNodes.test.ts +6 -9
  96. package/src/annotations/svgNodes.ts +3 -3
  97. package/src/annotations/toolMap.test.ts +37 -2
  98. package/src/annotations/toolMap.ts +13 -0
  99. package/src/annotations/types.ts +27 -0
  100. package/src/canvas/AGENTS.md +9 -10
  101. package/src/canvas/CameraInput.test.tsx +136 -0
  102. package/src/canvas/CameraInput.tsx +322 -0
  103. package/src/canvas/CanvasStack.tsx +47 -58
  104. package/src/canvas/LinkedCursor.less +16 -0
  105. package/src/canvas/LinkedCursor.tsx +54 -0
  106. package/src/canvas/Stage.test.tsx +33 -4
  107. package/src/canvas/Stage.tsx +47 -37
  108. package/src/canvas/cameraRegistry.test.ts +55 -0
  109. package/src/canvas/cameraRegistry.ts +86 -0
  110. package/src/canvas/cameraView.test.ts +60 -0
  111. package/src/canvas/cameraView.ts +61 -0
  112. package/src/canvas/cameraZoom.test.ts +46 -0
  113. package/src/canvas/cameraZoom.ts +24 -0
  114. package/src/canvas/index.ts +23 -2
  115. package/src/canvas/sharedScope.test.tsx +94 -0
  116. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  117. package/src/chrome/regions/PaletteRegion.less +0 -7
  118. package/src/chrome/regions/SidebarRegion.less +12 -2
  119. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  120. package/src/config/builder.test.ts +2 -1
  121. package/src/config/builder.ts +15 -8
  122. package/src/config/entry.test.ts +3 -1
  123. package/src/config/fromConfigField.test.ts +14 -0
  124. package/src/config/fromConfigField.ts +19 -4
  125. package/src/config/index.ts +4 -1
  126. package/src/config/path.ts +2 -5
  127. package/src/config/resolve.ts +15 -1
  128. package/src/config/sectionTree.test.ts +12 -6
  129. package/src/config/sectionTree.ts +15 -5
  130. package/src/config/types.ts +34 -9
  131. package/src/controls/ControlPanel.less +1 -1
  132. package/src/controls/ControlPanel.stories.tsx +3 -2
  133. package/src/controls/ControlPanel.test.tsx +76 -13
  134. package/src/controls/ControlPanel.tsx +80 -11
  135. package/src/controls/inDialog.test.tsx +18 -0
  136. package/src/controls/inDialog.tsx +2 -10
  137. package/src/index.test.ts +1 -1
  138. package/src/index.ts +23 -5
  139. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  140. package/src/instrument/types.ts +10 -1
  141. package/src/lab/Lab.nebula.test.tsx +11 -2
  142. package/src/lab/Lab.test.tsx +3 -1
  143. package/src/lab/Lab.tsx +87 -76
  144. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  145. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  146. package/src/lab/LabFullChrome.stories.tsx +1 -0
  147. package/src/lab/LabHeader.test.tsx +24 -14
  148. package/src/lab/LabHeader.tsx +11 -12
  149. package/src/lab/LabShell.less +8 -17
  150. package/src/lab/LabSwitcher.less +6 -6
  151. package/src/lab/LabZoom.test.tsx +223 -0
  152. package/src/lab/LabZoom.tsx +170 -0
  153. package/src/lab/index.ts +1 -0
  154. package/src/loupe/AGENTS.md +7 -6
  155. package/src/loupe/LoupeGestures.tsx +15 -10
  156. package/src/loupe/TrialLoupe.tsx +5 -5
  157. package/src/loupe/index.ts +1 -0
  158. package/src/loupe/loupeActions.ts +6 -2
  159. package/src/overview/AGENTS.md +38 -0
  160. package/src/overview/OverviewMarks.test.tsx +100 -0
  161. package/src/overview/OverviewMarks.tsx +93 -0
  162. package/src/overview/TrialOverview.less +53 -0
  163. package/src/overview/TrialOverview.stories.tsx +50 -0
  164. package/src/overview/TrialOverview.test.tsx +116 -0
  165. package/src/overview/TrialOverview.tsx +376 -0
  166. package/src/overview/index.ts +2 -0
  167. package/src/passthrough/weasel-ui.test.ts +2 -0
  168. package/src/passthrough/weasel-ui.ts +23 -4
  169. package/src/primitives/FloatingPanel.test.tsx +11 -0
  170. package/src/primitives/FloatingPanel.tsx +2 -5
  171. package/src/primitives/JobProgress.less +1 -1
  172. package/src/primitives/Legend.stories.tsx +1 -0
  173. package/src/primitives/Readout.browser.test.tsx +137 -0
  174. package/src/primitives/Readout.less +12 -0
  175. package/src/primitives/Readout.stories.tsx +58 -0
  176. package/src/primitives/Readout.test.tsx +93 -0
  177. package/src/primitives/Readout.tsx +56 -0
  178. package/src/primitives/index.ts +2 -0
  179. package/src/specimen/Specimen.stories.tsx +1 -0
  180. package/src/specimen/Specimen.tsx +21 -4
  181. package/src/state/Persistence.browser.test.tsx +47 -0
  182. package/src/state/labRecords.test.ts +4 -1
  183. package/src/state/store.test.ts +9 -5
  184. package/src/state/usePersistedState.test.tsx +1 -1
  185. package/src/state/useTrialState.test.tsx +1 -1
  186. package/src/styles.less +3 -0
  187. package/src/theme/Interstellar.stories.tsx +4 -6
  188. package/src/theme/base.less +21 -47
  189. package/src/theme/interstellar.test.ts +1 -1
  190. package/src/theme/interstellar.theme.json +10 -3
  191. package/src/tools/labTool.ts +6 -2
  192. package/src/trial/Trial.annotations.test.tsx +16 -0
  193. package/src/trial/Trial.less +0 -13
  194. package/src/trial/Trial.loupe.test.tsx +73 -1
  195. package/src/trial/Trial.stories.tsx +2 -0
  196. package/src/trial/Trial.tsx +23 -1
  197. package/src/trial/TrialChrome.tsx +21 -3
  198. package/src/trial/trialOps.test.ts +4 -4
  199. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  200. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  201. package/dist/_dts/index-DINmCuLA.d.ts +0 -453
  202. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  203. package/dist/_dts/usePanZoom-Bq73pwFe.d.ts +0 -117
  204. package/dist/chunk-2AYGEN57.js +0 -32
  205. package/dist/chunk-2AYGEN57.js.map +0 -1
  206. package/dist/chunk-AWH3SRPW.js.map +0 -1
  207. package/dist/chunk-BVL7Y574.js.map +0 -1
  208. package/dist/chunk-ERFMC6WU.js.map +0 -1
  209. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  210. package/dist/chunk-HFRE4QQV.js.map +0 -1
  211. package/dist/chunk-RS3HRQWU.js.map +0 -1
  212. package/dist/chunk-SBXNQW4G.js +0 -3
  213. package/dist/chunk-TT5C5S6R.js +0 -368
  214. package/dist/chunk-TT5C5S6R.js.map +0 -1
  215. package/dist/chunk-USO6TERP.js.map +0 -1
  216. package/dist/chunk-VQ47KWD5.js.map +0 -1
  217. package/dist/chunk-WUJXJC5O.js +0 -14568
  218. package/dist/chunk-WUJXJC5O.js.map +0 -1
  219. package/dist/chunk-ZORVNOKX.js.map +0 -1
  220. package/src/canvas/usePanZoom.test.ts +0 -244
  221. package/src/canvas/usePanZoom.ts +0 -147
  222. package/src/state/Persistence.stories.tsx +0 -75
package/src/lab/Lab.tsx CHANGED
@@ -12,7 +12,8 @@ import {
12
12
  } from 'react';
13
13
  import { useStore } from 'zustand/react';
14
14
  import { AnnotationPreloadContext } from '../annotations/preload';
15
- import { ANNOTATION_TOOLS } from '../annotations/toolMap';
15
+ import { labAnnotationTools } from '../annotations/toolMap';
16
+ import { CameraRegistryContext, createCameraRegistry } from '../canvas/cameraRegistry';
16
17
  import {
17
18
  LabAsideRegion,
18
19
  LabFooterRegion,
@@ -50,10 +51,11 @@ import {
50
51
  } from '../trial/trialOps';
51
52
  import { useLabFitWarning } from './fitCheck';
52
53
  import { LabContext, type LabContextValue } from './LabContext';
53
- import { LabHeader } from './LabHeader';
54
+ import { LabHeader, LabThemeSwitcher } from './LabHeader';
54
55
  import { LabPalette } from './LabPalette';
55
56
  import { LabShell } from './LabShell';
56
57
  import type { LabPage } from './LabSwitcher';
58
+ import { LabZoom } from './LabZoom';
57
59
  import { createPanelHostRegistry, PanelHostContext } from './panelHost';
58
60
  import { type PanelDescriptor, type TrialLayout, Workspace } from './Workspace';
59
61
 
@@ -104,6 +106,9 @@ interface LabBaseProps {
104
106
  /** Offer the header's add-trial control. Default `true`; a lab that opens
105
107
  * trials some other way passes `false`. */
106
108
  addTrial?: boolean;
109
+ /** Offer the header's zoom controls, which act on the focused trial's camera
110
+ * and answer Mod+=, Mod+- and Mod+0. Default `true`. */
111
+ zoom?: boolean;
107
112
  /** Tools offered lab-wide. A trial whose instrument declares none of its own
108
113
  * reflects and writes this slot. */
109
114
  tools?: readonly TrialTool[];
@@ -339,6 +344,7 @@ function LabRuntime({
339
344
  labChrome,
340
345
  suppress,
341
346
  addTrial,
347
+ zoom = true,
342
348
  tools,
343
349
  configRules,
344
350
  controls,
@@ -354,6 +360,7 @@ function LabRuntime({
354
360
  }, [instruments, store]);
355
361
 
356
362
  const trials = useStore(store, (s) => s.trials);
363
+ const [cameras] = useState(createCameraRegistry);
357
364
  const [focusPick, setFocusPick] = useState<string | null>(null);
358
365
  const focusedTrialId = trials.some((t) => t.id === focusPick)
359
366
  ? focusPick
@@ -478,10 +485,10 @@ function LabRuntime({
478
485
  // of them silently losing.
479
486
  // Declaring `annotations` on any instrument puts the drawing tools here, in
480
487
  // the lab's rail: one tool is armed across every trial.
481
- const annotates = instruments.some((i) => i.annotations != null);
488
+ const annotationTools = useMemo(() => labAnnotationTools(instruments), [instruments]);
482
489
  const labChromeAll = useMemo(
483
- () => labContributions([...(annotates ? ANNOTATION_TOOLS : []), ...(tools ?? [])], labChrome),
484
- [annotates, tools, labChrome],
490
+ () => labContributions([...annotationTools, ...(tools ?? [])], labChrome),
491
+ [annotationTools, tools, labChrome],
485
492
  );
486
493
  const hasFooterChrome = labChromeAll.some((c) => c.region === 'footer');
487
494
  const hasPaneChrome = labChromeAll.some((c) => c.region === 'sidebar' || c.region === 'aside');
@@ -612,79 +619,83 @@ function LabRuntime({
612
619
  <PersistenceContext.Provider value={opened.records}>
613
620
  <AnnotationPreloadContext.Provider value={opened.marks}>
614
621
  <LabContext.Provider value={contextValue}>
615
- <ThemeProvider
616
- theme={theme}
617
- selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
618
- className="lk-lab"
619
- style={backdropStyle}
620
- >
621
- <LabShell
622
- title={title ?? 'Labkit'}
623
- mode={modeValue}
624
- {...(pages ? { pages } : {})}
625
- {...(path !== undefined ? { path } : {})}
626
- footer={
627
- hasFooterChrome ? (
622
+ <CameraRegistryContext.Provider value={cameras}>
623
+ <ThemeProvider
624
+ theme={theme}
625
+ selection={{ mode: resolvedMode, density: density ?? 'comfortable' }}
626
+ className="lk-lab"
627
+ style={backdropStyle}
628
+ >
629
+ <LabShell
630
+ title={title ?? 'Labkit'}
631
+ mode={modeValue}
632
+ {...(pages ? { pages } : {})}
633
+ {...(path !== undefined ? { path } : {})}
634
+ footer={
635
+ hasFooterChrome ? (
636
+ <>
637
+ {footer}
638
+ <LabFooterRegion contributions={labChromeAll} />
639
+ </>
640
+ ) : (
641
+ footer
642
+ )
643
+ }
644
+ header={
628
645
  <>
629
- {footer}
630
- <LabFooterRegion contributions={labChromeAll} />
646
+ <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
647
+ {zoom ? <LabZoom /> : null}
648
+ {children}
649
+ <LabHeaderRegion contributions={labChromeAll} />
650
+ <LabThemeSwitcher />
631
651
  </>
632
- ) : (
633
- footer
634
- )
635
- }
636
- header={
637
- <>
638
- <LabHeader {...(addTrial !== undefined ? { addTrial } : {})} />
639
- {children}
640
- <LabHeaderRegion contributions={labChromeAll} />
641
- </>
642
- }
643
- >
644
- <PanelHostContext.Provider value={panelHostsRef.current}>
645
- <SurfaceContext.Provider value={surface}>
646
- <SurfaceCanvasContext.Provider value={surfaceCanvases}>
647
- <div className="lk-lab__body" ref={labBodyRef}>
648
- {outerSurface ? null : (
649
- // Two buffers stacked around the trials, both inert —
650
- // each tile takes input from its own box. A tile's marks
651
- // annotate the instrument's DOM from over it; an opaque
652
- // renderer sits under it, so the pane can still hold a
653
- // label. The under one is first so it paints first.
654
- <>
655
- <canvas
656
- className="lk-lab__surface lk-lab__surface--under"
657
- ref={(el) => {
658
- ownUnderRef.current = el;
659
- setOwnUnder(el);
660
- }}
661
- />
662
- <canvas
663
- className="lk-lab__surface lk-lab__surface--over"
664
- ref={(el) => {
665
- ownOverRef.current = el;
666
- setOwnOver(el);
667
- }}
668
- />
669
- </>
670
- )}
671
- {hasPaneChrome ? (
672
- <LabPanes contributions={labChromeAll}>
673
- <LabPalette contributions={labChromeAll} />
674
- {workspace}
675
- </LabPanes>
676
- ) : (
677
- <>
678
- <LabPalette contributions={labChromeAll} />
679
- {workspace}
680
- </>
681
- )}
682
- </div>
683
- </SurfaceCanvasContext.Provider>
684
- </SurfaceContext.Provider>
685
- </PanelHostContext.Provider>
686
- </LabShell>
687
- </ThemeProvider>
652
+ }
653
+ >
654
+ <PanelHostContext.Provider value={panelHostsRef.current}>
655
+ <SurfaceContext.Provider value={surface}>
656
+ <SurfaceCanvasContext.Provider value={surfaceCanvases}>
657
+ <div className="lk-lab__body" ref={labBodyRef}>
658
+ {outerSurface ? null : (
659
+ // Two buffers stacked around the trials, both inert —
660
+ // each tile takes input from its own box. A tile's marks
661
+ // annotate the instrument's DOM from over it; an opaque
662
+ // renderer sits under it, so the pane can still hold a
663
+ // label. The under one is first so it paints first.
664
+ <>
665
+ <canvas
666
+ className="lk-lab__surface lk-lab__surface--under"
667
+ ref={(el) => {
668
+ ownUnderRef.current = el;
669
+ setOwnUnder(el);
670
+ }}
671
+ />
672
+ <canvas
673
+ className="lk-lab__surface lk-lab__surface--over"
674
+ ref={(el) => {
675
+ ownOverRef.current = el;
676
+ setOwnOver(el);
677
+ }}
678
+ />
679
+ </>
680
+ )}
681
+ {hasPaneChrome ? (
682
+ <LabPanes contributions={labChromeAll}>
683
+ <LabPalette contributions={labChromeAll} />
684
+ {workspace}
685
+ </LabPanes>
686
+ ) : (
687
+ <>
688
+ <LabPalette contributions={labChromeAll} />
689
+ {workspace}
690
+ </>
691
+ )}
692
+ </div>
693
+ </SurfaceCanvasContext.Provider>
694
+ </SurfaceContext.Provider>
695
+ </PanelHostContext.Provider>
696
+ </LabShell>
697
+ </ThemeProvider>
698
+ </CameraRegistryContext.Provider>
688
699
  </LabContext.Provider>
689
700
  </AnnotationPreloadContext.Provider>
690
701
  </PersistenceContext.Provider>
@@ -1,4 +1,4 @@
1
- // Host pages for LabFit.stories. Each is scoped by its own class, so a story
1
+ // Host pages for LabFit.browser.test. Each is scoped by its own class, so a test
2
2
  // that mounts no fixture leaves the page untouched.
3
3
 
4
4
  // The mount every example documents: `html, body, #root { margin: 0; height: 100% }`.
@@ -1,15 +1,21 @@
1
- import type { Meta, StoryObj } from '@weasel-js/forge';
2
- import { expect, userEvent, waitFor, within } from '@weasel-js/forge/play';
1
+ import '@weasel-js/theme/tokens.css';
2
+ import 'windease/styles.css';
3
+ import '../styles.less';
4
+ import './LabFit.browser.test.less';
5
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
3
6
  import { type ReactNode, useLayoutEffect, useState } from 'react';
4
7
  import { createPortal } from 'react-dom';
8
+ import { afterEach, expect, test } from 'vitest';
9
+ import { page, userEvent } from 'vitest/browser';
5
10
  import type { LabContribution } from '../chrome/labTypes';
6
11
  import type { TrialContribution } from '../chrome/types';
7
12
  import type { Instrument } from '../instrument/types';
8
13
  import { Lab } from './Lab';
9
- import './LabFit.stories.less';
10
14
 
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.
15
+ // A lab must fit whatever it is mounted in without scrolling, which only real
16
+ // layout can show.
17
+
18
+ afterEach(cleanup);
13
19
 
14
20
  const Stub: Instrument = {
15
21
  name: 'Stub',
@@ -158,62 +164,34 @@ function fitProblems(fixture: Fixture, viewport: keyof typeof VIEWPORTS): string
158
164
  return problems;
159
165
  }
160
166
 
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([]);
167
+ const FIXTURES: Fixture[] = ['fullscreen', 'floating', 'wrapped', 'framed', 'sidebar'];
168
+ const CASES = FIXTURES.flatMap((fixture) =>
169
+ (Object.keys(VIEWPORTS) as (keyof typeof VIEWPORTS)[]).map((viewport) => ({ fixture, viewport })),
170
+ );
171
+
172
+ test.each(CASES)('$fixture fits a $viewport viewport', async ({ fixture, viewport }) => {
173
+ const { width, height } = VIEWPORTS[viewport].styles;
174
+ await page.viewport(Number.parseInt(width, 10), Number.parseInt(height, 10));
175
+ render(
176
+ <BodyHost host={HOST[fixture]}>
177
+ <Lab
178
+ title={`Fit: ${fixture}`}
179
+ instruments={[Stub]}
180
+ defaultInstrument="Stub"
181
+ chrome={fixture === 'floating' ? [floater] : undefined}
182
+ labChrome={fixture === 'sidebar' ? [sidebarTree] : undefined}
183
+ />
184
+ </BodyHost>,
185
+ );
186
+ if (fixture === 'floating') {
187
+ await userEvent.click(await screen.findByRole('button', { name: 'Undock Floater' }));
188
+ }
189
+ await waitFor(
190
+ () => {
191
+ const problems = fitProblems(fixture, viewport);
192
+ if (problems.length > 0) throw new Error(problems.join('; '));
188
193
  },
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');
194
+ { timeout: 2000 },
195
+ );
196
+ expect(fitProblems(fixture, viewport)).toEqual([]);
197
+ });
@@ -252,6 +252,7 @@ const runLog = defineInstrument<{ lines: string[] }, Record<string, never>>({
252
252
  const meta: Meta<typeof Lab> = {
253
253
  title: 'labkit/Lab/FullChrome',
254
254
  component: Lab,
255
+ tags: ['gallery'],
255
256
  parameters: { layout: 'fullscreen' },
256
257
  };
257
258
  export default meta;
@@ -58,28 +58,38 @@ describe('<LabHeader>', () => {
58
58
  <Lab instruments={[Stub, Other]} defaultInstrument="Stub" addTrial={false} />,
59
59
  );
60
60
  expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
61
- expect(screen.getByRole('radio', { name: 'Auto' })).toBeInTheDocument();
61
+ expect(screen.getByRole('button', { name: /^Theme: Auto/ })).toBeInTheDocument();
62
62
  unmount();
63
63
  render(<Lab instruments={[Stub]} defaultInstrument="Stub" addTrial={false} />);
64
64
  expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
65
65
  });
66
66
 
67
- it('exposes the color mode as a three-way choice', () => {
67
+ it('rotates the color mode from one button', async () => {
68
+ const { default: userEvent } = await import('@testing-library/user-event');
69
+ const user = userEvent.setup();
68
70
  render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
69
- for (const label of ['Auto', 'Light', 'Dark']) {
70
- expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
71
+ expect(screen.queryByRole('radiogroup')).toBeNull();
72
+ for (const [now, next] of [
73
+ ['Auto', 'Light'],
74
+ ['Light', 'Dark'],
75
+ ['Dark', 'Auto'],
76
+ ]) {
77
+ const button = screen.getByRole('button', { name: `Theme: ${now} — click for ${next}` });
78
+ expect(button.querySelector('svg')).not.toBeNull();
79
+ expect(button).toHaveTextContent('');
80
+ await user.click(button);
71
81
  }
82
+ expect(screen.getByRole('button', { name: /^Theme: Auto/ })).toBeInTheDocument();
72
83
  });
73
84
 
74
- it('draws each color mode as a glyph, with the word only as its name', () => {
75
- // The assertion above passes whether the segment holds text or an icon,
76
- // because both routes produce the same accessible name. This is the one
77
- // that can tell them apart.
78
- render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
79
- for (const label of ['Auto', 'Light', 'Dark']) {
80
- const segment = screen.getByRole('radio', { name: label });
81
- expect(segment.querySelector('svg')).not.toBeNull();
82
- expect(segment).toHaveTextContent('');
83
- }
85
+ it('puts the theme switch after the header content a consumer passes', () => {
86
+ render(
87
+ <Lab instruments={[Stub]} defaultInstrument="Stub">
88
+ <button type="button">Consumer</button>
89
+ </Lab>,
90
+ );
91
+ const consumer = screen.getByRole('button', { name: 'Consumer' });
92
+ const theme = screen.getByRole('button', { name: /^Theme: / });
93
+ expect(consumer.compareDocumentPosition(theme) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
84
94
  });
85
95
  });
@@ -1,11 +1,10 @@
1
1
  import { AddIcon } from '@weasel-js/ui';
2
- import { ColorModeControl, MenuButton } from '../passthrough/weasel-ui';
2
+ import { MenuButton, ThemeSwitcher } from '../passthrough/weasel-ui';
3
3
  import { useLabContext } from './LabContext';
4
4
 
5
- /** The controls `<Lab>` puts in its header: add a trial, and choose the color
6
- * mode. Both drive `LabContext`, which carried them with no UI at all — so
7
- * every consumer rebuilt these two. Rendered before a consumer's own header
8
- * content, which still lands beside them. */
5
+ /** The control `<Lab>` puts at the start of its header: add a trial. It drives
6
+ * `LabContext`, which carried it with no UI at all — so every consumer rebuilt
7
+ * it. Rendered before a consumer's own header content. */
9
8
  export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
10
9
  const lab = useLabContext();
11
10
  const only = lab.instruments.length === 1 ? lab.instruments[0] : null;
@@ -29,13 +28,13 @@ export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
29
28
  onAction={(name) => lab.addTrial(name)}
30
29
  />
31
30
  )}
32
-
33
- <ColorModeControl
34
- className="lk-lab-header__mode"
35
- variant="flat"
36
- value={lab.mode}
37
- onChange={lab.setMode}
38
- />
39
31
  </>
40
32
  );
41
33
  }
34
+
35
+ /** The lab's color-mode switch, which `<Lab>` puts at the end of its header,
36
+ * after a consumer's own header content. */
37
+ export function LabThemeSwitcher() {
38
+ const lab = useLabContext();
39
+ return <ThemeSwitcher value={lab.mode} onChange={lab.setMode} />;
40
+ }
@@ -155,21 +155,12 @@
155
155
  align-self: center;
156
156
  }
157
157
 
158
- // `Select` is a form field and defaults to `width: 100%`. Left to resolve that
159
- // against a shrink-to-fit header row it swallows the slack, which pushed the
160
- // consumer's own header content down to min-content and wrapped the row to
161
- // three lines. See the `.lk-root` prefix convention in theme/base.less.
162
- // The mode toggle is a preference, not a primary action: it sits quiet until
163
- // the pointer is on it.
164
- .lk-lab-header__mode {
165
- opacity: 0.75;
166
- // A consumer's own header content shares this row and does not shrink, so
167
- // without this the segments compress past their labels and the pill track
168
- // disappears entirely.
169
- flex-shrink: 0;
170
-
171
- &:hover,
172
- &:focus-within {
173
- opacity: 1;
174
- }
158
+ // The readout's digit count changes as the zoom does (80% to 125%), so it sits
159
+ // in a cell sized for the widest value the default range reaches, and the
160
+ // buttons either side of it stay put.
161
+ .lk-lab-zoom__readout {
162
+ display: inline-block;
163
+ min-width: 5ch;
164
+ text-align: center;
165
+ font-variant-numeric: tabular-nums;
175
166
  }
@@ -29,9 +29,9 @@
29
29
  &:hover { color: var(--wzl-accent-fg); }
30
30
 
31
31
  &:focus-visible {
32
- outline: 2px solid var(--wzl-focus-ring, currentColor);
32
+ outline: 2px solid var(--wzl-focus-ring);
33
33
  outline-offset: 2px;
34
- border-radius: var(--wzl-radius-sm, 3px);
34
+ border-radius: var(--wzl-radius-sm);
35
35
  }
36
36
  }
37
37
 
@@ -64,22 +64,22 @@
64
64
  // surface, the way `FloatingPanel` does.
65
65
  background: var(--wzl-surface);
66
66
  border: 1px solid var(--wzl-border);
67
- border-radius: var(--wzl-radius-md, 5px);
67
+ border-radius: var(--wzl-radius-md);
68
68
  box-shadow: 0 6px 20px var(--wzl-shadow);
69
69
  }
70
70
 
71
71
  .lk-switcher-item {
72
72
  padding: var(--wzl-space-xs) var(--wzl-space-sm);
73
- border-radius: var(--wzl-radius-sm, 3px);
73
+ border-radius: var(--wzl-radius-sm);
74
74
  color: var(--wzl-fg);
75
75
  font-size: var(--wzl-font-size);
76
76
  text-decoration: none;
77
77
  white-space: nowrap;
78
78
 
79
- &:hover { background: var(--wzl-surface-hover, var(--wzl-surface)); }
79
+ &:hover { background: var(--wzl-surface-hover); }
80
80
 
81
81
  &:focus-visible {
82
- outline: 2px solid var(--wzl-focus-ring, currentColor);
82
+ outline: 2px solid var(--wzl-focus-ring);
83
83
  outline-offset: -2px;
84
84
  }
85
85
  }