@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
@@ -2,7 +2,11 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  width: 100%;
5
+ // Fills a container of definite height. Where the container has none, the
6
+ // percentage drops out and the shell takes the viewport's height instead.
5
7
  height: 100vh;
8
+ height: 100dvh;
9
+ max-height: 100%;
6
10
  background: var(--wzl-surface);
7
11
  }
8
12
 
@@ -35,42 +39,100 @@
35
39
  .lk-shell-header-actions {
36
40
  margin-left: auto;
37
41
  display: flex;
38
- // A flex container reports its first item's baseline, so this is what
39
- // carries the row's baseline up to the header.
40
- align-items: baseline;
42
+ // Every control here runs at the one control height and shares a middle.
43
+ // The row still hands the header its first item's baseline, which is what
44
+ // lines the controls' labels up with the title.
45
+ align-items: center;
41
46
  gap: var(--wzl-space-sm);
42
47
  }
43
48
 
49
+ // A toolbar dropped into the header is a run of header controls, not a strip of
50
+ // its own: it takes the header's control height rather than the compact one
51
+ // trial toolbars use, and none of the strip's box.
52
+ .lk-shell-header-actions .lk-toolbar {
53
+ --wzl-control-h: inherit;
54
+ min-height: 0;
55
+ padding: 0;
56
+ background: none;
57
+ border: 0;
58
+ }
59
+
44
60
  .lk-shell-body {
61
+ display: flex;
62
+ flex-direction: column;
45
63
  flex: 1;
46
64
  min-height: 0;
65
+ min-width: 0;
47
66
  overflow: auto;
48
67
  padding: var(--lk-workspace-pad);
49
68
  }
50
69
 
51
- // `.lk-shell-body` is a block container, so `flex: 1` here resolves to nothing
52
- // and the workspace's `height: 100%` collapses to zero.
53
70
  .lk-lab__body {
54
71
  display: flex;
55
- height: 100%;
72
+ flex: 1;
56
73
  min-height: 0;
57
74
  position: relative;
58
75
 
59
- > .lk-workspace {
76
+ > .lk-workspace,
77
+ > .lk-workspace-host {
78
+ min-width: 0;
79
+ }
80
+ }
81
+
82
+ .lk-lab__panes {
83
+ flex: 1;
84
+ min-width: 0;
85
+ }
86
+
87
+ .lk-lab__sidebar {
88
+ display: flex;
89
+ flex-direction: column;
90
+ height: 100%;
91
+ overflow-y: auto;
92
+ background: var(--wzl-surface);
93
+ border-right: 1px solid var(--wzl-border);
94
+ }
95
+
96
+ .lk-lab__aside {
97
+ display: flex;
98
+ flex-direction: column;
99
+ height: 100%;
100
+ overflow-y: auto;
101
+ background: var(--wzl-surface);
102
+ border-left: 1px solid var(--wzl-border);
103
+ }
104
+
105
+ .lk-lab__main {
106
+ display: flex;
107
+ min-width: 0;
108
+ height: 100%;
109
+
110
+ > .lk-workspace,
111
+ > .lk-workspace-host {
112
+ flex: 1;
60
113
  min-width: 0;
61
114
  }
62
115
  }
63
116
 
64
- // The lab's one shared drawing buffer. Over the trials, because a tile's marks
65
- // annotate the instrument's own DOM; under the viewport chrome, whose z-overlay
66
- // outranks it; and inert, because every tile takes input from its own box.
117
+ // The lab's two shared drawing buffers, stacked around the trials: marks that
118
+ // annotate an instrument go over its DOM, an opaque renderer goes under it so
119
+ // the pane can still hold a label or a control. Both sit under the viewport
120
+ // chrome, whose z-overlay outranks them, and both are inert — every tile takes
121
+ // input from its own box.
67
122
  .lk-lab__surface {
68
123
  position: absolute;
69
124
  inset: 0;
70
- z-index: 1;
71
125
  pointer-events: none;
72
126
  }
73
127
 
128
+ .lk-lab__surface--under {
129
+ z-index: 0;
130
+ }
131
+
132
+ .lk-lab__surface--over {
133
+ z-index: 1;
134
+ }
135
+
74
136
  .lk-shell-footer {
75
137
  border-top: 1px solid var(--wzl-border);
76
138
  padding: var(--wzl-space-xs) var(--wzl-space-md);
@@ -98,10 +160,6 @@
98
160
  // against a shrink-to-fit header row it swallows the slack, which pushed the
99
161
  // consumer's own header content down to min-content and wrapped the row to
100
162
  // three lines. See the `.lk-root` prefix convention in theme/base.less.
101
- .lk-root .lk-lab-header__add-select {
102
- width: 160px;
103
- }
104
-
105
163
  // The mode toggle is a preference, not a primary action: it sits quiet until
106
164
  // the pointer is on it.
107
165
  .lk-lab-header__mode {
@@ -1,4 +1,7 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite';
1
+ import type { Meta, StoryObj } from '@weasel-js/forge';
2
+ import { expect, within } from '@weasel-js/forge/play';
3
+ import { Select, ToggleBar } from '../passthrough/weasel-ui';
4
+ import { Toolbar } from '../primitives/Toolbar';
2
5
  import { LabShell } from './LabShell';
3
6
 
4
7
  const meta: Meta<typeof LabShell> = {
@@ -37,3 +40,49 @@ export const WithFooter: Story = {
37
40
  footer: <span>Status: ready</span>,
38
41
  },
39
42
  };
43
+
44
+ // jsdom has no layout, so this runs in the browser story projects.
45
+ export const HeaderControlsLineUp: Story = {
46
+ args: {
47
+ title: 'My Lab',
48
+ header: (
49
+ <>
50
+ <button type="button">Add</button>
51
+ <ToggleBar
52
+ ariaLabel="Mode"
53
+ items={[
54
+ { value: 'a', label: 'A' },
55
+ { value: 'b', label: 'B' },
56
+ ]}
57
+ value="a"
58
+ onChange={() => {}}
59
+ />
60
+ <Select
61
+ aria-label="Font"
62
+ width="fit"
63
+ selectedKey="oswald"
64
+ options={[{ value: 'oswald', label: 'Oswald' }]}
65
+ />
66
+ <Toolbar aria-label="Actions">
67
+ <Toolbar.Group>
68
+ <Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
69
+ </Toolbar.Group>
70
+ </Toolbar>
71
+ </>
72
+ ),
73
+ children: <p>Body content.</p>,
74
+ },
75
+ play: async ({ canvasElement }) => {
76
+ const canvas = within(canvasElement);
77
+ const boxes = [
78
+ canvas.getByRole('button', { name: 'Add' }),
79
+ canvas.getByRole('radiogroup', { name: 'Mode' }),
80
+ canvas.getByRole('button', { name: /Font/ }),
81
+ canvas.getByRole('button', { name: 'Undo' }),
82
+ ].map((el) => el.getBoundingClientRect());
83
+ const heights = boxes.map((box) => Math.round(box.height * 2) / 2);
84
+ const middles = boxes.map((box) => Math.round((box.top + box.height / 2) * 2) / 2);
85
+ await expect(heights).toEqual(heights.map(() => heights[0]));
86
+ await expect(middles).toEqual(middles.map(() => middles[0]));
87
+ },
88
+ };
@@ -1,10 +1,8 @@
1
- import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
2
1
  import type { ReactNode } from 'react';
3
2
  import type { LabMode } from '../state/types';
4
- import { interstellarTheme } from '../theme/interstellar';
3
+ import { LabRoot } from './LabRoot';
5
4
  import type { LabPage } from './LabSwitcher';
6
5
  import { LabSwitcher } from './LabSwitcher';
7
- import { useResolvedMode } from './useSystemMode';
8
6
 
9
7
  /** Props for `<LabShell>`. */
10
8
  export interface LabShellProps {
@@ -27,34 +25,27 @@ export interface LabShellProps {
27
25
  /** Page frame for a lab: a titled header, a body, and an optional footer,
28
26
  * themed for the resolved color mode. Presentational only — use `<Lab>` when
29
27
  * the trial runtime is wanted too. */
30
- export function LabShell({ title, children, header, footer, mode = 'auto',
31
- pages, path }: LabShellProps) {
32
- const resolved = useResolvedMode(mode);
33
- const outer = useThemeOptional();
34
-
35
- const shell = (
36
- // Overlays portal here rather than to the themed wrapper above: `.lk-root`
37
- // carries the element defaults a lab's bare markup is styled by.
38
- <div className="lk-root lk-shell" data-wzl-portal-host="">
28
+ export function LabShell({
29
+ title,
30
+ children,
31
+ header,
32
+ footer,
33
+ mode = 'auto',
34
+ pages,
35
+ path,
36
+ }: LabShellProps) {
37
+ return (
38
+ <LabRoot mode={mode} className="lk-shell">
39
39
  <header className="lk-shell-header">
40
- {pages && pages.length > 0
41
- ? <LabSwitcher title={title} pages={pages} path={path}
42
- className="lk-shell-title" />
43
- : <h1 className="lk-shell-title">{title}</h1>}
40
+ {pages && pages.length > 0 ? (
41
+ <LabSwitcher title={title} pages={pages} path={path} className="lk-shell-title" />
42
+ ) : (
43
+ <h1 className="lk-shell-title">{title}</h1>
44
+ )}
44
45
  {header && <div className="lk-shell-header-actions">{header}</div>}
45
46
  </header>
46
47
  <main className="lk-shell-body">{children}</main>
47
48
  {footer && <footer className="lk-shell-footer">{footer}</footer>}
48
- </div>
49
- );
50
-
51
- // Inside <Lab> the theme is already applied on `.lk-lab`; wrapping again
52
- // would only add a div.
53
- return outer ? (
54
- shell
55
- ) : (
56
- <ThemeProvider theme={interstellarTheme} mode={resolved}>
57
- {shell}
58
- </ThemeProvider>
49
+ </LabRoot>
59
50
  );
60
51
  }
@@ -6,7 +6,7 @@
6
6
  .lk-switcher-title {
7
7
  margin: 0;
8
8
  font-size: var(--wzl-font-size-xl);
9
- line-height: var(--wzl-line-height-tight, 1.2);
9
+ line-height: var(--wzl-leading-snug);
10
10
  }
11
11
 
12
12
  .lk-switcher-trigger {
@@ -21,13 +21,17 @@
21
21
  border: 0;
22
22
  padding: 0;
23
23
  cursor: pointer;
24
+ // A title is a click target now, and a consumer's header is usually a
25
+ // crowded flex row -- left to wrap, `brick-icons corpus` breaks after the
26
+ // hyphen and the control becomes three ragged lines with a caret adrift.
27
+ white-space: nowrap;
24
28
 
25
- &:hover { color: var(--wzl-text-accent, var(--wzl-text)); }
29
+ &:hover { color: var(--wzl-accent); }
26
30
 
27
31
  &:focus-visible {
28
32
  outline: 2px solid var(--wzl-focus-ring, currentColor);
29
33
  outline-offset: 2px;
30
- border-radius: var(--wzl-radius-sm, 2px);
34
+ border-radius: var(--wzl-radius-sm, 3px);
31
35
  }
32
36
  }
33
37
 
@@ -50,22 +54,25 @@
50
54
  position: absolute;
51
55
  top: calc(100% + var(--wzl-space-xs));
52
56
  left: 0;
53
- z-index: var(--wzl-z-popover, 60);
57
+ z-index: var(--wzl-z-overlay);
54
58
  min-width: max(100%, 11rem);
55
59
  display: flex;
56
60
  flex-direction: column;
57
61
  padding: var(--wzl-space-xs);
58
- background: var(--wzl-surface-raised);
62
+ // `--wzl-surface-raised` is translucent by design -- it is for panels that
63
+ // blur what sits behind them. A menu has to be read, so it takes the opaque
64
+ // surface, the way `FloatingPanel` does.
65
+ background: var(--wzl-surface);
59
66
  border: 1px solid var(--wzl-border);
60
- border-radius: var(--wzl-radius-md, 6px);
61
- box-shadow: var(--wzl-shadow-md, 0 6px 20px rgb(0 0 0 / 35%));
67
+ border-radius: var(--wzl-radius-md, 5px);
68
+ box-shadow: 0 6px 20px var(--wzl-shadow);
62
69
  }
63
70
 
64
71
  .lk-switcher-item {
65
72
  padding: var(--wzl-space-xs) var(--wzl-space-sm);
66
73
  border-radius: var(--wzl-radius-sm, 3px);
67
- color: var(--wzl-text);
68
- font-size: var(--wzl-font-size-md);
74
+ color: var(--wzl-fg);
75
+ font-size: var(--wzl-font-size);
69
76
  text-decoration: none;
70
77
  white-space: nowrap;
71
78
 
@@ -80,6 +87,6 @@
80
87
  // The open page stays in the list rather than being filtered out: a menu that
81
88
  // drops its current entry shifts every other one as you move between pages.
82
89
  .lk-switcher-item[aria-current='page'] {
83
- color: var(--wzl-text-strong, var(--wzl-text));
84
- font-weight: var(--wzl-font-weight-semibold, 600);
90
+ color: var(--wzl-fg);
91
+ font-weight: var(--wzl-font-weight-bold);
85
92
  }
@@ -1,7 +1,7 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { currentPage, LabSwitcher } from './LabSwitcher';
4
3
  import { LabShell } from './LabShell';
4
+ import { currentPage, LabSwitcher } from './LabSwitcher';
5
5
 
6
6
  const PAGES = [
7
7
  { href: '/corpus', label: 'Wall' },
@@ -19,8 +19,7 @@ describe('currentPage', () => {
19
19
  it('survives a query string, a trailing slash and a leftover extension', () => {
20
20
  // A dev server maps `/stats` to `stats.html` and serves both spellings, so
21
21
  // an old bookmark must still resolve to the same page.
22
- for (const path of ['/stats?kind=base', '/stats/', '/stats.html',
23
- '/lab/stats.html#top']) {
22
+ for (const path of ['/stats?kind=base', '/stats/', '/stats.html', '/lab/stats.html#top']) {
24
23
  expect(currentPage(path, PAGES)).toBe(1);
25
24
  }
26
25
  });
@@ -34,7 +33,9 @@ describe('<LabSwitcher>', () => {
34
33
  it('shows the title and nothing else until it is opened', () => {
35
34
  render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
36
35
  expect(screen.getByRole('button', { name: /corpus stats/ })).toHaveAttribute(
37
- 'aria-expanded', 'false');
36
+ 'aria-expanded',
37
+ 'false',
38
+ );
38
39
  expect(screen.queryByRole('menu')).toBeNull();
39
40
  });
40
41
 
@@ -54,8 +55,7 @@ describe('<LabSwitcher>', () => {
54
55
  render(<LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />);
55
56
  open();
56
57
  expect(screen.getByRole('menuitem', { name: 'Wall' }).tagName).toBe('A');
57
- expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute(
58
- 'href', '/corpus');
58
+ expect(screen.getByRole('menuitem', { name: 'Wall' })).toHaveAttribute('href', '/corpus');
59
59
  });
60
60
 
61
61
  it('closes on Escape and on a press outside it', () => {
@@ -63,7 +63,8 @@ describe('<LabSwitcher>', () => {
63
63
  <div>
64
64
  <LabSwitcher title="corpus stats" pages={PAGES} path="/stats" />
65
65
  <button type="button">elsewhere</button>
66
- </div>);
66
+ </div>,
67
+ );
67
68
  open();
68
69
  fireEvent.keyDown(document, { key: 'Escape' });
69
70
  expect(screen.queryByRole('menu')).toBeNull();
@@ -76,7 +77,7 @@ describe('<LabSwitcher>', () => {
76
77
  it('stays a plain heading when there is nowhere to switch to', () => {
77
78
  // A disclosure arrow promising a menu of the page you are already on is
78
79
  // worse than no control.
79
- render(<LabSwitcher title="corpus stats" pages={[PAGES[1]!]} path="/stats" />);
80
+ render(<LabSwitcher title="corpus stats" pages={PAGES.slice(1, 2)} path="/stats" />);
80
81
  expect(screen.getByRole('heading', { name: 'corpus stats' })).toBeInTheDocument();
81
82
  expect(screen.queryByRole('button')).toBeNull();
82
83
  });
@@ -84,7 +85,11 @@ describe('<LabSwitcher>', () => {
84
85
 
85
86
  describe('<LabShell> with pages', () => {
86
87
  it('turns its title into the switcher', () => {
87
- render(<LabShell title="corpus stats" pages={PAGES} path="/stats">body</LabShell>);
88
+ render(
89
+ <LabShell title="corpus stats" pages={PAGES} path="/stats">
90
+ body
91
+ </LabShell>,
92
+ );
88
93
  expect(screen.getByRole('button', { name: /corpus stats/ })).toBeInTheDocument();
89
94
  });
90
95
 
@@ -25,8 +25,10 @@ export interface LabSwitcherProps {
25
25
  * to `stats.html` serves both spellings, and a bookmark from before the URLs
26
26
  * lost their extension still resolves to the same page. */
27
27
  export function currentPage(path: string, pages: readonly LabPage[]): number {
28
- const here = path.split('?')[0]!.split('#')[0]!
29
- .replace(/\/$/, '').replace(/\.html$/, '');
28
+ const here = path
29
+ .replace(/[?#].*$/, '')
30
+ .replace(/\/$/, '')
31
+ .replace(/\.html$/, '');
30
32
  return pages.findIndex((p) => here.endsWith(p.href.replace(/\.html$/, '')));
31
33
  }
32
34
 
@@ -44,8 +46,10 @@ export function LabSwitcher({ title, pages, path, className }: LabSwitcherProps)
44
46
  const [open, setOpen] = useState(false);
45
47
  const root = useRef<HTMLDivElement>(null);
46
48
  const menuId = useId();
47
- const here = currentPage(path ?? (typeof window === 'undefined'
48
- ? '' : window.location.pathname), pages);
49
+ const here = currentPage(
50
+ path ?? (typeof window === 'undefined' ? '' : window.location.pathname),
51
+ pages,
52
+ );
49
53
 
50
54
  const close = useCallback(() => setOpen(false), []);
51
55
 
@@ -80,5 +80,5 @@
80
80
  width: 260px;
81
81
  height: auto;
82
82
  max-height: 60%;
83
- box-shadow: var(--wzl-shadow-panel, 0 2px 8px rgb(0 0 0 / 0.3));
83
+ box-shadow: 0 2px 8px var(--wzl-shadow);
84
84
  }
@@ -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 { Workspace } from './Workspace';
3
3
 
4
4
  const meta: Meta<typeof Workspace> = {
@@ -10,6 +10,7 @@ function fakeHandle(): SurfaceHandle {
10
10
  invalidateAll: vi.fn(),
11
11
  invalidateRects: vi.fn(),
12
12
  registerTile: vi.fn(),
13
+ registerClear: vi.fn(() => () => {}),
13
14
  registerPainter: vi.fn(() => () => {}),
14
15
  containerRef: vi.fn(),
15
16
  getContainer: vi.fn(() => null),
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { describeElement, findOvershoot } from './fitCheck';
3
+
4
+ const bound = { top: 0, left: 0, bottom: 600, right: 800 };
5
+ const box = (bottom: number, right: number) => ({ top: 0, left: 0, bottom, right });
6
+
7
+ describe('findOvershoot', () => {
8
+ it('returns null when everything is inside the bound', () => {
9
+ expect(findOvershoot(bound, [{ item: 'a', edges: box(600, 800) }])).toBeNull();
10
+ });
11
+
12
+ it('ignores sub-pixel overshoot within the tolerance', () => {
13
+ expect(findOvershoot(bound, [{ item: 'a', edges: box(600.8, 800) }])).toBeNull();
14
+ });
15
+
16
+ it('names the item reaching furthest past the bound', () => {
17
+ const hit = findOvershoot(bound, [
18
+ { item: 'header', edges: box(640, 800) },
19
+ { item: 'tile', edges: box(900, 800) },
20
+ { item: 'footer', edges: box(700, 800) },
21
+ ]);
22
+ expect(hit).toEqual({ item: 'tile', by: 300, axis: 'y' });
23
+ });
24
+
25
+ it('compares both axes and reports the worse one', () => {
26
+ const hit = findOvershoot(bound, [
27
+ { item: 'tall', edges: box(650, 800) },
28
+ { item: 'wide', edges: box(600, 1000) },
29
+ ]);
30
+ expect(hit).toEqual({ item: 'wide', by: 200, axis: 'x' });
31
+ });
32
+
33
+ it('keeps the first item on a tie, which is the outermost in document order', () => {
34
+ const hit = findOvershoot(bound, [
35
+ { item: 'shell', edges: box(616, 800) },
36
+ { item: 'footer', edges: box(616, 800) },
37
+ ]);
38
+ expect(hit?.item).toBe('shell');
39
+ });
40
+ });
41
+
42
+ describe('describeElement', () => {
43
+ it('writes the tag and every class', () => {
44
+ const el = document.createElement('DIV');
45
+ el.className = 'lk-shell-body extra';
46
+ expect(describeElement(el)).toBe('div.lk-shell-body.extra');
47
+ });
48
+
49
+ it('places a classless element by its nearest classed ancestor', () => {
50
+ const outer = document.createElement('div');
51
+ outer.className = 'lk-trial';
52
+ const mid = document.createElement('div');
53
+ const leaf = document.createElement('canvas');
54
+ outer.appendChild(mid).appendChild(leaf);
55
+ expect(describeElement(leaf)).toBe('canvas in div.lk-trial');
56
+ });
57
+ });
@@ -0,0 +1,125 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /** An element's edges, in the same coordinate space as the bound it is tested against. */
4
+ export interface Edges {
5
+ top: number;
6
+ left: number;
7
+ bottom: number;
8
+ right: number;
9
+ }
10
+
11
+ export interface Overshoot<T> {
12
+ item: T;
13
+ /** How far past the bound the item reaches, in px, on whichever axis is worse. */
14
+ by: number;
15
+ axis: 'x' | 'y';
16
+ }
17
+
18
+ /** The item reaching furthest past the bottom or right of `bound`. On a tie the
19
+ * first item wins, so given document order it names the outermost box. */
20
+ export function findOvershoot<T>(
21
+ bound: Edges,
22
+ items: Iterable<{ item: T; edges: Edges }>,
23
+ tolerance = 1,
24
+ ): Overshoot<T> | null {
25
+ let worst: Overshoot<T> | null = null;
26
+ for (const { item, edges } of items) {
27
+ const y = edges.bottom - bound.bottom;
28
+ const x = edges.right - bound.right;
29
+ const by = Math.max(x, y);
30
+ if (by <= tolerance) continue;
31
+ if (!worst || by > worst.by) worst = { item, by, axis: y >= x ? 'y' : 'x' };
32
+ }
33
+ return worst;
34
+ }
35
+
36
+ function tagAndClasses(el: Element): string {
37
+ return el.tagName.toLowerCase() + [...el.classList].map((c) => `.${c}`).join('');
38
+ }
39
+
40
+ /** `div.lk-shell-body` — the tag and classes, which is what a reader greps for.
41
+ * A classless element is placed by its nearest classed ancestor. */
42
+ export function describeElement(el: Element): string {
43
+ if (el.classList.length > 0) return tagAndClasses(el);
44
+ let up = el.parentElement;
45
+ while (up && up.classList.length === 0) up = up.parentElement;
46
+ return up ? `${tagAndClasses(el)} in ${tagAndClasses(up)}` : tagAndClasses(el);
47
+ }
48
+
49
+ function edgesOf(el: Element): Edges {
50
+ const r = el.getBoundingClientRect();
51
+ return { top: r.top, left: r.left, bottom: r.bottom, right: r.right };
52
+ }
53
+
54
+ function* candidates(root: Element): Iterable<{ item: Element; edges: Edges }> {
55
+ yield { item: root, edges: edgesOf(root) };
56
+ for (const el of root.querySelectorAll('*')) yield { item: el, edges: edgesOf(el) };
57
+ }
58
+
59
+ function overflows(el: Element): boolean {
60
+ return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
61
+ }
62
+
63
+ /** Why a mounted lab scrolls, or null when it fits. The document only counts
64
+ * when the lab is what reaches past the viewport, so a lab embedded in a long
65
+ * page does not report the page's own scroll. */
66
+ export function explainLabScroll(lab: Element, shellBody: Element): string | null {
67
+ if (overflows(shellBody)) {
68
+ const r = shellBody.getBoundingClientRect();
69
+ const bound = {
70
+ top: r.top,
71
+ left: r.left,
72
+ bottom: r.top + shellBody.clientHeight,
73
+ right: r.left + shellBody.clientWidth,
74
+ };
75
+ const hit = findOvershoot(bound, candidates(shellBody));
76
+ return hit
77
+ ? `.lk-shell-body scrolls: ${describeElement(hit.item)} reaches ${Math.round(hit.by)}px past it (${hit.axis})`
78
+ : '.lk-shell-body scrolls';
79
+ }
80
+
81
+ const doc = document.scrollingElement ?? document.documentElement;
82
+ if (!overflows(doc)) return null;
83
+ const r = lab.getBoundingClientRect();
84
+ const bound = {
85
+ top: r.top,
86
+ left: r.left,
87
+ bottom: r.top + Math.min(r.height, doc.clientHeight),
88
+ right: r.left + Math.min(r.width, doc.clientWidth),
89
+ };
90
+ const hit = findOvershoot(bound, candidates(lab));
91
+ return hit
92
+ ? `the page scrolls: ${describeElement(hit.item)} reaches ${Math.round(hit.by)}px past the lab's box (${hit.axis})`
93
+ : null;
94
+ }
95
+
96
+ const SETTLE_MS = 300;
97
+
98
+ /** Dev only: warns once, naming the culprit, when a mounted lab scrolls. */
99
+ export function useLabFitWarning(labBody: Element | null): void {
100
+ useEffect(() => {
101
+ if (process.env.NODE_ENV === 'production') return;
102
+ if (!labBody || typeof ResizeObserver === 'undefined') return;
103
+ const lab = labBody.closest('.lk-lab');
104
+ const shellBody = labBody.closest('.lk-shell-body');
105
+ if (!lab || !shellBody) return;
106
+
107
+ // A shrinking window overflows for a frame or two before the workspace
108
+ // re-lays out, so only overflow that outlasts the resize is reported.
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ const observer = new ResizeObserver(() => {
111
+ clearTimeout(timer);
112
+ timer = setTimeout(() => {
113
+ const why = explainLabScroll(lab, shellBody);
114
+ if (!why) return;
115
+ observer.disconnect();
116
+ console.warn(`[labkit] <Lab> should fit its container without scrolling, but ${why}.`);
117
+ }, SETTLE_MS);
118
+ });
119
+ for (const el of [document.documentElement, lab, shellBody, labBody]) observer.observe(el);
120
+ return () => {
121
+ clearTimeout(timer);
122
+ observer.disconnect();
123
+ };
124
+ }, [labBody]);
125
+ }
package/src/lab/index.ts CHANGED
@@ -4,6 +4,8 @@ export type { LabContextValue } from './LabContext';
4
4
  export { LabContext, useLabContext } from './LabContext';
5
5
  export type { LabPaletteProps } from './LabPalette';
6
6
  export { LabPalette } from './LabPalette';
7
+ export type { LabRootProps } from './LabRoot';
8
+ export { LabRoot } from './LabRoot';
7
9
  export type { LabShellProps } from './LabShell';
8
10
  export { LabShell } from './LabShell';
9
11
  export type { LabPage, LabSwitcherProps } from './LabSwitcher';
@@ -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 { useState } from 'react';
3
3
  import { LayerList, type LayerTreeNode } from './LayerList';
4
4