@weasel-js/labkit 1.4.3 → 1.5.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 (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +123 -348
  53. package/dist/index.js +794 -837
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +169 -117
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +163 -62
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -0,0 +1,219 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { type ReactNode, useLayoutEffect, useState } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
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 storybook 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
+ // Storybook 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');
@@ -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,17 +37,11 @@ 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
 
@@ -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
  }
@@ -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
 
@@ -42,35 +46,72 @@
42
46
  }
43
47
 
44
48
  .lk-shell-body {
49
+ display: flex;
50
+ flex-direction: column;
45
51
  flex: 1;
46
52
  min-height: 0;
53
+ min-width: 0;
47
54
  overflow: auto;
48
55
  padding: var(--lk-workspace-pad);
49
56
  }
50
57
 
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
58
  .lk-lab__body {
54
59
  display: flex;
55
- height: 100%;
60
+ flex: 1;
56
61
  min-height: 0;
57
62
  position: relative;
58
63
 
59
- > .lk-workspace {
64
+ > .lk-workspace,
65
+ > .lk-workspace-host {
66
+ min-width: 0;
67
+ }
68
+ }
69
+
70
+ .lk-lab__panes {
71
+ flex: 1;
72
+ min-width: 0;
73
+ }
74
+
75
+ .lk-lab__sidebar {
76
+ display: flex;
77
+ flex-direction: column;
78
+ height: 100%;
79
+ overflow-y: auto;
80
+ background: var(--wzl-surface);
81
+ border-right: 1px solid var(--wzl-border);
82
+ }
83
+
84
+ .lk-lab__main {
85
+ display: flex;
86
+ min-width: 0;
87
+ height: 100%;
88
+
89
+ > .lk-workspace,
90
+ > .lk-workspace-host {
91
+ flex: 1;
60
92
  min-width: 0;
61
93
  }
62
94
  }
63
95
 
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.
96
+ // The lab's two shared drawing buffers, stacked around the trials: marks that
97
+ // annotate an instrument go over its DOM, an opaque renderer goes under it so
98
+ // the pane can still hold a label or a control. Both sit under the viewport
99
+ // chrome, whose z-overlay outranks them, and both are inert — every tile takes
100
+ // input from its own box.
67
101
  .lk-lab__surface {
68
102
  position: absolute;
69
103
  inset: 0;
70
- z-index: 1;
71
104
  pointer-events: none;
72
105
  }
73
106
 
107
+ .lk-lab__surface--under {
108
+ z-index: 0;
109
+ }
110
+
111
+ .lk-lab__surface--over {
112
+ z-index: 1;
113
+ }
114
+
74
115
  .lk-shell-footer {
75
116
  border-top: 1px solid var(--wzl-border);
76
117
  padding: var(--wzl-space-xs) var(--wzl-space-md);
@@ -98,10 +139,6 @@
98
139
  // against a shrink-to-fit header row it swallows the slack, which pushed the
99
140
  // consumer's own header content down to min-content and wrapped the row to
100
141
  // 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
142
  // The mode toggle is a preference, not a primary action: it sits quiet until
106
143
  // the pointer is on it.
107
144
  .lk-lab-header__mode {
@@ -2,6 +2,8 @@ import { ThemeProvider, useThemeOptional } from '@weasel-js/theme/react';
2
2
  import type { ReactNode } from 'react';
3
3
  import type { LabMode } from '../state/types';
4
4
  import { interstellarTheme } from '../theme/interstellar';
5
+ import type { LabPage } from './LabSwitcher';
6
+ import { LabSwitcher } from './LabSwitcher';
5
7
  import { useResolvedMode } from './useSystemMode';
6
8
 
7
9
  /** Props for `<LabShell>`. */
@@ -14,12 +16,19 @@ export interface LabShellProps {
14
16
  footer?: ReactNode;
15
17
  /** Color mode. "auto" (default) follows prefers-color-scheme. */
16
18
  mode?: LabMode;
19
+ /** The project's other labs. Given two or more, the title becomes the way
20
+ * to reach them; given none, it stays a plain heading. */
21
+ pages?: readonly LabPage[];
22
+ /** The path the switcher reads to mark the open page. Defaults to the
23
+ * current location. */
24
+ path?: string;
17
25
  }
18
26
 
19
27
  /** Page frame for a lab: a titled header, a body, and an optional footer,
20
28
  * themed for the resolved color mode. Presentational only — use `<Lab>` when
21
29
  * the trial runtime is wanted too. */
22
- export function LabShell({ title, children, header, footer, mode = 'auto' }: LabShellProps) {
30
+ export function LabShell({ title, children, header, footer, mode = 'auto',
31
+ pages, path }: LabShellProps) {
23
32
  const resolved = useResolvedMode(mode);
24
33
  const outer = useThemeOptional();
25
34
 
@@ -28,7 +37,10 @@ export function LabShell({ title, children, header, footer, mode = 'auto' }: Lab
28
37
  // carries the element defaults a lab's bare markup is styled by.
29
38
  <div className="lk-root lk-shell" data-wzl-portal-host="">
30
39
  <header className="lk-shell-header">
31
- <h1 className="lk-shell-title">{title}</h1>
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>}
32
44
  {header && <div className="lk-shell-header-actions">{header}</div>}
33
45
  </header>
34
46
  <main className="lk-shell-body">{children}</main>
@@ -0,0 +1,92 @@
1
+ .lk-switcher {
2
+ position: relative;
3
+ display: inline-block;
4
+ }
5
+
6
+ .lk-switcher-title {
7
+ margin: 0;
8
+ font-size: var(--wzl-font-size-xl);
9
+ line-height: var(--wzl-line-height-tight, 1.2);
10
+ }
11
+
12
+ .lk-switcher-trigger {
13
+ display: inline-flex;
14
+ align-items: baseline;
15
+ gap: var(--wzl-space-xs);
16
+ // The title IS the control, so it keeps the title's type and gives up every
17
+ // other button affordance until it is pointed at.
18
+ font: inherit;
19
+ color: inherit;
20
+ background: none;
21
+ border: 0;
22
+ padding: 0;
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;
28
+
29
+ &:hover { color: var(--wzl-text-accent, var(--wzl-text)); }
30
+
31
+ &:focus-visible {
32
+ outline: 2px solid var(--wzl-focus-ring, currentColor);
33
+ outline-offset: 2px;
34
+ border-radius: var(--wzl-radius-sm, 2px);
35
+ }
36
+ }
37
+
38
+ .lk-switcher-caret {
39
+ // Sized in em so it tracks the title rather than needing a second rule per
40
+ // heading size.
41
+ width: 0.36em;
42
+ height: 0.36em;
43
+ border-right: 0.09em solid currentColor;
44
+ border-bottom: 0.09em solid currentColor;
45
+ transform: translateY(-0.18em) rotate(45deg);
46
+ opacity: 0.6;
47
+ }
48
+
49
+ .lk-switcher-trigger[aria-expanded='true'] .lk-switcher-caret {
50
+ transform: translateY(0.06em) rotate(-135deg);
51
+ }
52
+
53
+ .lk-switcher-menu {
54
+ position: absolute;
55
+ top: calc(100% + var(--wzl-space-xs));
56
+ left: 0;
57
+ z-index: var(--wzl-z-popover, 60);
58
+ min-width: max(100%, 11rem);
59
+ display: flex;
60
+ flex-direction: column;
61
+ padding: var(--wzl-space-xs);
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);
66
+ border: 1px solid var(--wzl-border);
67
+ border-radius: var(--wzl-radius-md, 6px);
68
+ box-shadow: var(--wzl-shadow-md, 0 6px 20px rgb(0 0 0 / 35%));
69
+ }
70
+
71
+ .lk-switcher-item {
72
+ padding: var(--wzl-space-xs) var(--wzl-space-sm);
73
+ border-radius: var(--wzl-radius-sm, 3px);
74
+ color: var(--wzl-text);
75
+ font-size: var(--wzl-font-size-md);
76
+ text-decoration: none;
77
+ white-space: nowrap;
78
+
79
+ &:hover { background: var(--wzl-surface-hover, var(--wzl-surface)); }
80
+
81
+ &:focus-visible {
82
+ outline: 2px solid var(--wzl-focus-ring, currentColor);
83
+ outline-offset: -2px;
84
+ }
85
+ }
86
+
87
+ // The open page stays in the list rather than being filtered out: a menu that
88
+ // drops its current entry shifts every other one as you move between pages.
89
+ .lk-switcher-item[aria-current='page'] {
90
+ color: var(--wzl-text-strong, var(--wzl-text));
91
+ font-weight: var(--wzl-font-weight-semibold, 600);
92
+ }