@weasel-js/labkit 1.2.0 → 1.3.0-pre.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 (196) hide show
  1. package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
  2. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  3. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  4. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  5. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  6. package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
  7. package/dist/canvas/index.d.ts +7 -3
  8. package/dist/canvas/index.js +2 -1
  9. package/dist/chrome/index.d.ts +86 -0
  10. package/dist/chrome/index.js +8 -0
  11. package/dist/chrome/index.js.map +1 -0
  12. package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
  13. package/dist/chunk-4TMMVIDM.js.map +1 -0
  14. package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
  15. package/dist/chunk-HFDVGF4X.js.map +1 -0
  16. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  17. package/dist/chunk-NS54R52R.js.map +1 -0
  18. package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
  19. package/dist/chunk-O2BULFHX.js.map +1 -0
  20. package/dist/chunk-O7NVSCLN.js +266 -0
  21. package/dist/chunk-O7NVSCLN.js.map +1 -0
  22. package/dist/chunk-PO6L3NSH.js +366 -0
  23. package/dist/chunk-PO6L3NSH.js.map +1 -0
  24. package/dist/chunk-PQJ5B2U2.js +27 -0
  25. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  26. package/dist/chunk-RVNN4CHQ.js +212 -0
  27. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  28. package/dist/chunk-TLGRYALP.js +6151 -0
  29. package/dist/chunk-TLGRYALP.js.map +1 -0
  30. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  31. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  32. package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
  33. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  34. package/dist/chunk-ZZAYVX4X.js +505 -0
  35. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  36. package/dist/controls/index.d.ts +19 -5
  37. package/dist/controls/index.js +3 -1
  38. package/dist/dragdrop/index.d.ts +4 -2
  39. package/dist/index.d.ts +246 -106
  40. package/dist/index.js +673 -559
  41. package/dist/index.js.map +1 -1
  42. package/dist/layers/index.d.ts +5 -3
  43. package/dist/layers/index.js +2 -1
  44. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  45. package/dist/passthrough/weasel-canvas.js +1 -1
  46. package/dist/passthrough/weasel-ui.d.ts +3799 -3672
  47. package/dist/passthrough/weasel-ui.js +2 -2
  48. package/dist/primitives/index.d.ts +102 -8
  49. package/dist/primitives/index.js +4 -1
  50. package/dist/state/index.d.ts +3 -2
  51. package/dist/state/index.js +2 -2
  52. package/dist/styles.css +542 -153
  53. package/dist/surface/index.js +2 -1
  54. package/dist/ui/layers/index.js +4 -3
  55. package/dist/undo/index.d.ts +4 -2
  56. package/package.json +16 -7
  57. package/src/canvas/AGENTS.md +4 -0
  58. package/src/canvas/CanvasStack.tsx +6 -2
  59. package/src/canvas/useLayerScheduler.ts +22 -15
  60. package/src/canvas/usePanZoom.test.ts +80 -0
  61. package/src/canvas/usePanZoom.ts +16 -2
  62. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  63. package/src/chrome/builtins.test.ts +153 -0
  64. package/src/chrome/builtins.tsx +205 -0
  65. package/src/chrome/index.ts +25 -0
  66. package/src/chrome/merge.test.ts +51 -0
  67. package/src/chrome/merge.ts +52 -0
  68. package/src/chrome/regions/PaletteRegion.less +14 -0
  69. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  70. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  71. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  72. package/src/chrome/regions/StatusRegion.tsx +31 -0
  73. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  74. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  75. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  76. package/src/chrome/regions/ViewportRegion.less +42 -0
  77. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  78. package/src/chrome/regions/regions.test.tsx +81 -0
  79. package/src/chrome/types.ts +127 -0
  80. package/src/config/builder.test.ts +87 -0
  81. package/src/config/builder.ts +224 -0
  82. package/src/config/fromConfigField.test.ts +93 -0
  83. package/src/config/fromConfigField.ts +74 -0
  84. package/src/config/index.ts +22 -0
  85. package/src/config/resolve.test.ts +120 -0
  86. package/src/config/resolve.ts +67 -0
  87. package/src/config/rules.ts +68 -0
  88. package/src/config/types.ts +108 -0
  89. package/src/config/useConfigSchema.ts +30 -0
  90. package/src/config/visible.ts +20 -0
  91. package/src/controls/ControlPanel.less +4 -58
  92. package/src/controls/ControlPanel.stories.tsx +86 -0
  93. package/src/controls/ControlPanel.test.tsx +223 -10
  94. package/src/controls/ControlPanel.tsx +202 -203
  95. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  96. package/src/dragdrop/DragGhost.less +1 -1
  97. package/src/dragdrop/Palette.less +1 -1
  98. package/src/index.ts +25 -3
  99. package/src/instrument/SineWave.smoke.test.tsx +8 -1
  100. package/src/instrument/defineInstrument.test.ts +38 -1
  101. package/src/instrument/defineInstrument.ts +31 -4
  102. package/src/instrument/index.ts +1 -0
  103. package/src/instrument/types.ts +27 -4
  104. package/src/instrument/validateConfigSchema.test.ts +13 -3
  105. package/src/instrument/validateConfigSchema.ts +7 -10
  106. package/src/lab/Lab.chrome.test.tsx +65 -0
  107. package/src/lab/Lab.tsx +58 -15
  108. package/src/lab/LabContext.ts +6 -0
  109. package/src/lab/LabFullChrome.stories.tsx +308 -0
  110. package/src/lab/LabHeader.test.tsx +42 -0
  111. package/src/lab/LabHeader.tsx +57 -0
  112. package/src/lab/LabPalette.test.tsx +35 -0
  113. package/src/lab/LabPalette.tsx +34 -0
  114. package/src/lab/LabShell.less +50 -3
  115. package/src/lab/Workspace.less +1 -30
  116. package/src/lab/Workspace.test.tsx +20 -10
  117. package/src/lab/Workspace.tsx +5 -8
  118. package/src/lab/index.ts +2 -0
  119. package/src/layers/AGENTS.md +1 -1
  120. package/src/layers/LayerList.less +31 -7
  121. package/src/layers/LayerList.tsx +17 -4
  122. package/src/passthrough/weasel-ui.ts +23 -0
  123. package/src/primitives/DragHandleGlyph.tsx +29 -0
  124. package/src/primitives/FloatingPanel.less +28 -0
  125. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  126. package/src/primitives/FloatingPanel.test.tsx +240 -0
  127. package/src/primitives/FloatingPanel.tsx +190 -0
  128. package/src/primitives/FpsMeter.test.tsx +44 -0
  129. package/src/primitives/FpsMeter.tsx +20 -9
  130. package/src/primitives/JobProgress.less +69 -0
  131. package/src/primitives/JobProgress.stories.tsx +48 -0
  132. package/src/primitives/JobProgress.tsx +65 -0
  133. package/src/primitives/Legend.less +47 -0
  134. package/src/primitives/Legend.stories.tsx +32 -0
  135. package/src/primitives/Legend.test.tsx +57 -0
  136. package/src/primitives/Legend.tsx +40 -0
  137. package/src/primitives/Sidebar.less +32 -2
  138. package/src/primitives/StatusBar.less +7 -0
  139. package/src/primitives/StatusBar.tsx +10 -3
  140. package/src/primitives/Toolbar.less +50 -3
  141. package/src/primitives/Toolbar.test.tsx +67 -1
  142. package/src/primitives/Toolbar.tsx +72 -6
  143. package/src/primitives/ZoomControl.less +31 -0
  144. package/src/primitives/ZoomControl.tsx +76 -0
  145. package/src/primitives/floating.entry.test.tsx +14 -0
  146. package/src/primitives/index.ts +10 -2
  147. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  148. package/src/primitives/useRovingTabIndex.ts +79 -0
  149. package/src/state/store.ts +11 -3
  150. package/src/state/toolSlot.test.ts +36 -0
  151. package/src/state/types.ts +4 -0
  152. package/src/styles.less +6 -0
  153. package/src/surface/useTiledSurface.ts +14 -10
  154. package/src/test-setup.ts +26 -0
  155. package/src/theme/base.less +19 -2
  156. package/src/theme/interstellar.tokens.json +6 -2
  157. package/src/tools/types.ts +24 -0
  158. package/src/trial/Trial.config.test.tsx +113 -0
  159. package/src/trial/Trial.job.test.tsx +13 -15
  160. package/src/trial/Trial.less +115 -10
  161. package/src/trial/Trial.stories.tsx +1 -0
  162. package/src/trial/Trial.test.tsx +67 -96
  163. package/src/trial/Trial.tsx +80 -39
  164. package/src/trial/TrialChrome.tsx +99 -80
  165. package/src/trial/TrialDragContext.ts +10 -0
  166. package/src/trial/TrialTitleBar.tsx +41 -0
  167. package/src/trial/index.ts +1 -15
  168. package/src/trial/trialChrome.borders.test.ts +38 -0
  169. package/src/ui/layers/LayerStack.less +9 -9
  170. package/src/ui/layers/LayerStack.tsx +1 -13
  171. package/src/ui/properties/CurveField.less +2 -2
  172. package/src/ui/properties/CurveField.test.tsx +11 -11
  173. package/src/ui/properties/PropertyGroup.less +2 -2
  174. package/src/ui/properties/PropertyPanel.less +55 -41
  175. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  176. package/src/ui/properties/storyLayouts.tsx +2 -2
  177. package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
  178. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  179. package/dist/chunk-574LJAV4.js.map +0 -1
  180. package/dist/chunk-5R2ATYPJ.js +0 -201
  181. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  182. package/dist/chunk-73KA7WBO.js +0 -5992
  183. package/dist/chunk-73KA7WBO.js.map +0 -1
  184. package/dist/chunk-BOHF3PQO.js.map +0 -1
  185. package/dist/chunk-G5TJVQQT.js.map +0 -1
  186. package/dist/chunk-PMAU3SEE.js +0 -126
  187. package/dist/chunk-PMAU3SEE.js.map +0 -1
  188. package/dist/chunk-SFL7NFKN.js.map +0 -1
  189. package/dist/chunk-THBG7FQZ.js.map +0 -1
  190. package/dist/chunk-VUU5UXHE.js.map +0 -1
  191. package/src/instrument/capabilityDetector.test.ts +0 -64
  192. package/src/instrument/capabilityDetector.ts +0 -20
  193. package/src/trial/DefaultSidebar.tsx +0 -29
  194. package/src/trial/DefaultStatusBar.tsx +0 -19
  195. package/src/trial/DefaultToolbar.tsx +0 -92
  196. package/src/trial/slotTypes.ts +0 -54
@@ -0,0 +1,308 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useEffect, useRef } from 'react';
3
+ import type { ConfigField } from '../controls/types';
4
+ import { defineInstrument } from '../instrument/defineInstrument';
5
+ import type { Instrument, RenderContext } from '../instrument/types';
6
+ import type { JobEvent } from '../job/types';
7
+ import { Lab } from './Lab';
8
+ import { useLabContext } from './LabContext';
9
+
10
+ interface ScanConfig {
11
+ gain: number;
12
+ integrationMs: number;
13
+ source: string;
14
+ channel: string;
15
+ traceColor: string;
16
+ autoBaseline: boolean;
17
+ }
18
+
19
+ interface ScanState {
20
+ samples: number[];
21
+ marks: number[];
22
+ }
23
+
24
+ const PLOT = { w: 240, h: 120 };
25
+
26
+ function scanSchema(traceColor: string): ConfigField[] {
27
+ return [
28
+ { key: 'gain', label: 'Gain', type: 'slider', default: 1.4, min: 0.1, max: 4, step: 0.1 },
29
+ {
30
+ key: 'integrationMs',
31
+ label: 'Integration (ms)',
32
+ type: 'number',
33
+ default: 250,
34
+ min: 10,
35
+ max: 2000,
36
+ step: 10,
37
+ },
38
+ {
39
+ key: 'source',
40
+ label: 'Source',
41
+ type: 'select',
42
+ default: 'deuterium',
43
+ options: [
44
+ { value: 'deuterium', label: 'Deuterium' },
45
+ { value: 'tungsten', label: 'Tungsten' },
46
+ { value: 'xenon', label: 'Xenon flash' },
47
+ ],
48
+ },
49
+ { key: 'channel', label: 'Channel', type: 'text', default: 'CH-1', maxLength: 12 },
50
+ { key: 'traceColor', label: 'Trace', type: 'color', default: traceColor },
51
+ { key: 'autoBaseline', label: 'Auto baseline', type: 'checkbox', default: true },
52
+ ];
53
+ }
54
+
55
+ function wait(ms: number, signal: AbortSignal): Promise<void> {
56
+ return new Promise((resolve) => {
57
+ const timer = setTimeout(resolve, ms);
58
+ signal.addEventListener(
59
+ 'abort',
60
+ () => {
61
+ clearTimeout(timer);
62
+ resolve();
63
+ },
64
+ { once: true },
65
+ );
66
+ });
67
+ }
68
+
69
+ interface SweepSpec {
70
+ count: number;
71
+ everyMs: number;
72
+ /** Frame indices that report a `failed` event instead of a sample. */
73
+ dropped?: number[];
74
+ /** Frame index at which the whole run throws, and what it throws. */
75
+ faultAt?: number;
76
+ fault?: string;
77
+ }
78
+
79
+ async function* sweep(spec: SweepSpec, signal: AbortSignal): AsyncGenerator<JobEvent<number>> {
80
+ yield { kind: 'total', total: spec.count };
81
+ for (let i = 0; i < spec.count; i++) {
82
+ await wait(spec.everyMs, signal);
83
+ if (signal.aborted) return;
84
+ if (i === spec.faultAt) throw new Error(spec.fault ?? 'Sweep failed');
85
+ if (spec.dropped?.includes(i)) {
86
+ yield { kind: 'failed', index: i, error: `Frame ${i}: detector saturated` };
87
+ continue;
88
+ }
89
+ yield { kind: 'item', item: Math.sin(i * 0.55) * 42 + 58 };
90
+ }
91
+ }
92
+
93
+ // Rendered for its effect alone: a mark seeded on mount gives every trial one
94
+ // undoable edit, so Undo reads live instead of starting greyed out.
95
+ function SeedMarks({ ctx }: { ctx: RenderContext<ScanState, ScanConfig> }) {
96
+ const seeded = useRef(false);
97
+ useEffect(() => {
98
+ if (seeded.current) return;
99
+ seeded.current = true;
100
+ ctx.setState((prev) => ({ ...prev, marks: [...prev.marks, 64, 152] }));
101
+ }, [ctx]);
102
+ return null;
103
+ }
104
+
105
+ function scanInstrument(
106
+ name: string,
107
+ traceColor: string,
108
+ spec: SweepSpec,
109
+ ): Instrument<ScanState, ScanConfig, number> {
110
+ return defineInstrument<ScanState, ScanConfig, number>({
111
+ name,
112
+ defaultConfig: () => ({
113
+ gain: 1.4,
114
+ integrationMs: 250,
115
+ source: 'deuterium',
116
+ channel: 'CH-1',
117
+ traceColor,
118
+ autoBaseline: true,
119
+ }),
120
+ initialState: () => ({ samples: [], marks: [] }),
121
+ configSchema: () => scanSchema(traceColor),
122
+ render: (ctx) => <SeedMarks ctx={ctx} />,
123
+ canvas: {
124
+ initialView: { zoom: 1, pan: { x: 24, y: 24 } },
125
+ layers: [
126
+ {
127
+ id: 'grid',
128
+ draw: (c, { zoom }) => {
129
+ c.strokeStyle = '#8894a8';
130
+ c.globalAlpha = 0.3;
131
+ c.lineWidth = 1 / zoom;
132
+ for (let x = 0; x <= PLOT.w; x += 20) {
133
+ c.beginPath();
134
+ c.moveTo(x, 0);
135
+ c.lineTo(x, PLOT.h);
136
+ c.stroke();
137
+ }
138
+ for (let y = 0; y <= PLOT.h; y += 20) {
139
+ c.beginPath();
140
+ c.moveTo(0, y);
141
+ c.lineTo(PLOT.w, y);
142
+ c.stroke();
143
+ }
144
+ c.globalAlpha = 1;
145
+ },
146
+ },
147
+ {
148
+ id: 'trace',
149
+ draw: (c, { state, config, zoom }) => {
150
+ if (state.samples.length === 0) return;
151
+ c.strokeStyle = config.traceColor;
152
+ c.lineWidth = 2 / zoom;
153
+ c.beginPath();
154
+ state.samples.forEach((sample, i) => {
155
+ const x = i * 10;
156
+ const y = PLOT.h - Math.min(PLOT.h, sample * config.gain * 0.7);
157
+ if (i === 0) c.moveTo(x, y);
158
+ else c.lineTo(x, y);
159
+ });
160
+ c.stroke();
161
+ },
162
+ },
163
+ {
164
+ id: 'marks',
165
+ draw: (c, { state, zoom }) => {
166
+ c.strokeStyle = '#f08c00';
167
+ c.lineWidth = 1 / zoom;
168
+ for (const x of state.marks) {
169
+ c.beginPath();
170
+ c.moveTo(x, 0);
171
+ c.lineTo(x, PLOT.h);
172
+ c.stroke();
173
+ }
174
+ },
175
+ },
176
+ ],
177
+ },
178
+ layers: {
179
+ ids: [
180
+ { id: 'grid', label: 'Grid', alwaysOn: true },
181
+ { id: 'trace', label: 'Trace' },
182
+ { id: 'marks', label: 'Marks' },
183
+ ],
184
+ },
185
+ dragDrop: {
186
+ palette: [
187
+ { id: 'peak', label: 'Peak marker' },
188
+ { id: 'baseline', label: 'Baseline point' },
189
+ { id: 'window', label: 'Integration window' },
190
+ ],
191
+ onDrop: (worldPos, _item, state) => ({ ...state, marks: [...state.marks, worldPos.x] }),
192
+ },
193
+ undo: { snapshotOn: ['state.change', 'canvas.itemAdded'], maxDepth: 20 },
194
+ job: {
195
+ auto: true,
196
+ run: ({ signal }) => sweep(spec, signal),
197
+ onItem: (item, state) => ({ ...state, samples: [...state.samples, item] }),
198
+ },
199
+ });
200
+ }
201
+
202
+ const spectrometer = scanInstrument('Spectrometer', '#5ad1ff', { count: 24, everyMs: 1200 });
203
+ const beamProfile = scanInstrument('Beam profile', '#ffd166', {
204
+ count: 12,
205
+ everyMs: 900,
206
+ dropped: [4, 9],
207
+ });
208
+ const thermalDrift = scanInstrument('Thermal drift', '#ff8fa3', {
209
+ count: 8,
210
+ everyMs: 800,
211
+ faultAt: 3,
212
+ fault: 'Sensor bus timeout on channel 2',
213
+ });
214
+
215
+ /** Opens the other two trials and saves one snapshot per trial, so every
216
+ * toolbar shows its Load control and the header's Select has somewhere to
217
+ * add to. */
218
+ function SeedTrials() {
219
+ const lab = useLabContext();
220
+ const opened = useRef(false);
221
+ const saved = useRef(false);
222
+ useEffect(() => {
223
+ if (!opened.current) {
224
+ opened.current = true;
225
+ lab.addTrial('Beam profile');
226
+ lab.addTrial('Thermal drift');
227
+ return;
228
+ }
229
+ if (!saved.current && lab.trials.length === 3) {
230
+ saved.current = true;
231
+ for (const trial of lab.trials) lab.saveSnapshot(trial.id, 'Baseline');
232
+ }
233
+ }, [lab]);
234
+ return null;
235
+ }
236
+
237
+ const runLog = defineInstrument<{ lines: string[] }, Record<string, never>>({
238
+ name: 'Run log',
239
+ defaultConfig: () => ({}),
240
+ initialState: () => ({
241
+ lines: ['09:04 lamp warm', '09:06 slit 50 µm', '09:12 calibrated', '09:14 sweep armed'],
242
+ }),
243
+ render: ({ state }) => (
244
+ <div className="lk-run-log">
245
+ {state.lines.map((line) => (
246
+ <div key={line}>{line}</div>
247
+ ))}
248
+ </div>
249
+ ),
250
+ });
251
+
252
+ const meta: Meta<typeof Lab> = {
253
+ title: 'labkit/Lab/FullChrome',
254
+ component: Lab,
255
+ parameters: { layout: 'fullscreen' },
256
+ };
257
+ export default meta;
258
+
259
+ type Story = StoryObj<typeof Lab>;
260
+
261
+ /** Every chrome surface a lab renders, on screen at once: three instruments so
262
+ * the header offers its Select, three trials each carrying a title bar, the
263
+ * full toolbar, a control panel with one of every field type, the palette and
264
+ * layer list, and a status bar with a running job. Starts in dark, where the
265
+ * `nebula` backdrop paints. */
266
+ export const AllChrome: Story = {
267
+ args: {
268
+ instruments: [spectrometer, beamProfile, thermalDrift],
269
+ defaultInstrument: 'Spectrometer',
270
+ title: 'Optics bench',
271
+ storage: null,
272
+ mode: 'dark',
273
+ nebula: ['#3b5bdb', '#7048e8', '#0ca678'],
274
+ children: (
275
+ <>
276
+ <SeedTrials />
277
+ <span className="lk-run-tag">Run 42 · bench 3</span>
278
+ </>
279
+ ),
280
+ },
281
+ };
282
+
283
+ /** The chrome the other story cannot show at the same time: one instrument, so
284
+ * the header is an Add trial button; one trial, so Close is disabled; no
285
+ * config schema, canvas, undo or job, so the sidebar falls back to its
286
+ * placeholder and the toolbar keeps only what is left. */
287
+ export const SingleInstrument: Story = {
288
+ args: {
289
+ instruments: [runLog],
290
+ defaultInstrument: 'Run log',
291
+ title: 'Run log',
292
+ storage: null,
293
+ },
294
+ };
295
+
296
+ /** The shell footer, which `<Lab>` had no prop for — reaching it used to mean
297
+ * building `<LabShell>` yourself. Trial chrome is contributions now; see
298
+ * `labkit/Chrome/Regions` for those. */
299
+ export const WithFooter: Story = {
300
+ args: {
301
+ instruments: [spectrometer, beamProfile],
302
+ defaultInstrument: 'Spectrometer',
303
+ title: 'With footer',
304
+ storage: null,
305
+ mode: 'dark',
306
+ footer: <span className="lk-run-tag">Footer — reached through LabProps.footer</span>,
307
+ },
308
+ };
@@ -0,0 +1,42 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { Instrument } from '../instrument/types';
4
+ import { Lab } from './Lab';
5
+
6
+ const Stub: Instrument = {
7
+ name: 'Stub',
8
+ defaultConfig: () => ({}),
9
+ initialState: () => ({}),
10
+ render: () => <div>stub</div>,
11
+ };
12
+ const Other: Instrument = { ...Stub, name: 'Other' };
13
+
14
+ describe('<LabHeader>', () => {
15
+ it('offers an add-trial button when the lab has one instrument', () => {
16
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
17
+ expect(screen.getByRole('button', { name: /add trial/i })).toBeInTheDocument();
18
+ });
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();
25
+ });
26
+
27
+ it('adds a trial when the button is used', async () => {
28
+ const { default: userEvent } = await import('@testing-library/user-event');
29
+ const user = userEvent.setup();
30
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
31
+ expect(screen.getAllByLabelText(/^Trial Stub$/)).toHaveLength(1);
32
+ await user.click(screen.getByRole('button', { name: /add trial/i }));
33
+ expect(screen.getAllByLabelText(/^Trial Stub$/)).toHaveLength(2);
34
+ });
35
+
36
+ it('exposes the color mode as a three-way choice', () => {
37
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
38
+ for (const label of ['Auto', 'Light', 'Dark']) {
39
+ expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
40
+ }
41
+ });
42
+ });
@@ -0,0 +1,57 @@
1
+ import { AddIcon } from '@weasel-js/ui';
2
+ import { Select, ToggleBar } from '../passthrough/weasel-ui';
3
+ import type { LabMode } from '../state/types';
4
+ import { useLabContext } from './LabContext';
5
+
6
+ const MODES: { value: LabMode; label: string }[] = [
7
+ { value: 'auto', label: 'Auto' },
8
+ { value: 'light', label: 'Light' },
9
+ { value: 'dark', label: 'Dark' },
10
+ ];
11
+
12
+ /** The controls `<Lab>` puts in its header: add a trial, and choose the color
13
+ * mode. Both drive `LabContext`, which carried them with no UI at all — so
14
+ * every consumer rebuilt these two. Rendered before a consumer's own header
15
+ * content, which still lands beside them. */
16
+ export function LabHeader() {
17
+ const lab = useLabContext();
18
+ const only = lab.instruments.length === 1 ? lab.instruments[0] : null;
19
+
20
+ return (
21
+ <>
22
+ {only ? (
23
+ <button
24
+ type="button"
25
+ className="lk-lab-header__add"
26
+ onClick={() => lab.addTrial(only.name)}
27
+ >
28
+ <AddIcon size={16} />
29
+ <span>Add trial</span>
30
+ </button>
31
+ ) : (
32
+ // Held at null so the control stays an "add one" action rather than
33
+ // reading as the current instrument.
34
+ <Select
35
+ className="lk-lab-header__add-select"
36
+ aria-label="Add trial"
37
+ placeholder="Add trial…"
38
+ selectedKey={null}
39
+ options={lab.instruments.map((i) => ({ value: i.name, label: i.name }))}
40
+ onSelectionChange={(name) => {
41
+ if (name != null) lab.addTrial(String(name));
42
+ }}
43
+ />
44
+ )}
45
+
46
+ <ToggleBar
47
+ className="lk-lab-header__mode"
48
+ ariaLabel="Color mode"
49
+ items={MODES}
50
+ value={lab.mode}
51
+ onChange={(next) => {
52
+ if (next) lab.setMode(next);
53
+ }}
54
+ />
55
+ </>
56
+ );
57
+ }
@@ -0,0 +1,35 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { Instrument } from '../instrument/types';
4
+ import { Lab } from './Lab';
5
+
6
+ const Glyph = () => <svg />;
7
+ const bare: Instrument = {
8
+ name: 'Bare',
9
+ defaultConfig: () => ({}),
10
+ initialState: () => ({}),
11
+ render: () => null,
12
+ };
13
+ const tools = [
14
+ { id: 'pick', label: 'Pick', icon: Glyph },
15
+ { id: 'pan', label: 'Pan', icon: Glyph },
16
+ ];
17
+
18
+ describe('the lab palette', () => {
19
+ it('does not render when the lab declares no tools', () => {
20
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" />);
21
+ expect(screen.queryByRole('toolbar', { name: 'Tools' })).toBeNull();
22
+ });
23
+
24
+ it('renders one button per declared tool', () => {
25
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" tools={tools} />);
26
+ expect(screen.getByRole('button', { name: 'Pick' })).toBeInTheDocument();
27
+ expect(screen.getByRole('button', { name: 'Pan' })).toBeInTheDocument();
28
+ });
29
+
30
+ it('marks the chosen tool current after a click', () => {
31
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" tools={tools} />);
32
+ fireEvent.click(screen.getByRole('button', { name: 'Pan' }));
33
+ expect(screen.getByRole('button', { name: 'Pan' })).toHaveAttribute('aria-current', 'true');
34
+ });
35
+ });
@@ -0,0 +1,34 @@
1
+ import { useContext } from 'react';
2
+ import { useStore } from 'zustand/react';
3
+ import { PaletteRegion } from '../chrome/regions/PaletteRegion';
4
+ import type { TrialChromeContext, TrialContribution } from '../chrome/types';
5
+ import { LabStoreContext } from '../state/context';
6
+ import type { TrialTool } from '../tools/types';
7
+
8
+ /** Props for `<LabPalette>`. */
9
+ export interface LabPaletteProps {
10
+ tools: readonly TrialTool[];
11
+ }
12
+
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} />;
34
+ }
@@ -6,6 +6,12 @@
6
6
  background: var(--wzl-surface);
7
7
  }
8
8
 
9
+ // Standalone, the shell owns its surface. Inside `<Lab>` that surface is the
10
+ // one thing between the viewer and `.lk-lab`'s nebula, so it steps aside.
11
+ .lk-lab > .lk-shell {
12
+ background: transparent;
13
+ }
14
+
9
15
  .lk-shell-header {
10
16
  display: flex;
11
17
  align-items: center;
@@ -18,8 +24,8 @@
18
24
 
19
25
  .lk-shell-title {
20
26
  margin: 0;
21
- font-size: 16px;
22
- font-weight: 600;
27
+ font-size: var(--wzl-font-size-xl);
28
+ font-weight: var(--wzl-font-weight-bold);
23
29
  color: var(--wzl-fg);
24
30
  }
25
31
 
@@ -33,7 +39,19 @@
33
39
  flex: 1;
34
40
  min-height: 0;
35
41
  overflow: auto;
36
- padding: var(--wzl-space-md);
42
+ padding: var(--lk-workspace-pad);
43
+ }
44
+
45
+ // `.lk-shell-body` is a block container, so `flex: 1` here resolves to nothing
46
+ // and the workspace's `height: 100%` collapses to zero.
47
+ .lk-lab__body {
48
+ display: flex;
49
+ height: 100%;
50
+ min-height: 0;
51
+
52
+ > .lk-workspace {
53
+ min-width: 0;
54
+ }
37
55
  }
38
56
 
39
57
  .lk-shell-footer {
@@ -43,3 +61,32 @@
43
61
  color: var(--wzl-fg-muted);
44
62
  font-size: var(--wzl-font-size-sm);
45
63
  }
64
+
65
+ .lk-lab-header__add {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ gap: var(--wzl-space-xs);
69
+ }
70
+
71
+ // `Select` is a form field and defaults to `width: 100%`. Left to resolve that
72
+ // against a shrink-to-fit header row it swallows the slack, which pushed the
73
+ // consumer's own header content down to min-content and wrapped the row to
74
+ // three lines. See the `.lk-root` prefix convention in theme/base.less.
75
+ .lk-root .lk-lab-header__add-select {
76
+ width: 160px;
77
+ }
78
+
79
+ // The mode toggle is a preference, not a primary action: it sits quiet until
80
+ // the pointer is on it.
81
+ .lk-lab-header__mode {
82
+ opacity: 0.75;
83
+ // A consumer's own header content shares this row and does not shrink, so
84
+ // without this the segments compress past their labels and the pill track
85
+ // disappears entirely.
86
+ flex-shrink: 0;
87
+
88
+ &:hover,
89
+ &:focus-within {
90
+ opacity: 1;
91
+ }
92
+ }
@@ -5,36 +5,7 @@
5
5
  min-height: 0;
6
6
  }
7
7
 
8
- // Only rendered when `reorderable` — a tile is full of controls, so the drag
9
- // target has to be its own strip rather than the whole pane.
10
8
  .lk-trial-tile {
11
- display: flex;
12
- flex-direction: column;
13
9
  height: 100%;
14
-
15
- &__grip {
16
- flex: 0 0 auto;
17
- display: flex;
18
- align-items: center;
19
- justify-content: center;
20
- height: 14px;
21
- cursor: grab;
22
- border-radius: var(--wzl-radius-sm) var(--wzl-radius-sm) 0 0;
23
- background: var(--wzl-surface-sunken);
24
-
25
- &:active { cursor: grabbing; }
26
- }
27
-
28
- &__grip-dots {
29
- width: 28px;
30
- height: 3px;
31
- border-radius: 999px;
32
- background: var(--wzl-fg-muted);
33
- opacity: 0.5;
34
- }
35
-
36
- &__body {
37
- flex: 1;
38
- min-height: 0;
39
- }
10
+ min-height: 0;
40
11
  }
@@ -1,7 +1,15 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { describe, expect, test, vi } from 'vitest';
3
+ import { useTrialDrag } from '../trial/TrialDragContext';
3
4
  import { Workspace } from './Workspace';
4
5
 
6
+ /** Reports whether its tile supplied a drag source — the title bar is the
7
+ * drag surface now, so there is no grip element to assert on. */
8
+ function DragProbe({ label }: { label: string }) {
9
+ const drag = useTrialDrag();
10
+ return <div data-testid={`drag-${label}`}>{drag ? 'draggable' : 'static'}</div>;
11
+ }
12
+
5
13
  const VIEWPORT = { w: 800, h: 600 };
6
14
 
7
15
  describe('Workspace', () => {
@@ -88,13 +96,14 @@ describe('Workspace', () => {
88
96
  const onReorder = vi.fn();
89
97
  const { container } = render(
90
98
  <Workspace ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
91
- <div>a</div>
92
- <div>b</div>
99
+ <DragProbe label="a" />
100
+ <DragProbe label="b" />
93
101
  </Workspace>,
94
102
  );
95
- // The grid is controlled: it renders a handle per tile and commits nothing
96
- // itself. Order still comes from `ids`.
97
- expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(2);
103
+ // The grid is controlled: each tile offers itself as a drag source and
104
+ // commits nothing itself. Order still comes from `ids`.
105
+ expect(screen.getByTestId('drag-a')).toHaveTextContent('draggable');
106
+ expect(screen.getByTestId('drag-b')).toHaveTextContent('draggable');
98
107
  expect(onReorder).not.toHaveBeenCalled();
99
108
  const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
100
109
  el.getAttribute('data-node'),
@@ -102,13 +111,14 @@ describe('Workspace', () => {
102
111
  expect(nodes).toEqual(['a', 'b']);
103
112
  });
104
113
 
105
- test('renders no drag handles unless reorderable', () => {
106
- const { container } = render(
114
+ test('offers no drag source unless reorderable', () => {
115
+ render(
107
116
  <Workspace ids={['a', 'b']} viewport={VIEWPORT}>
108
- <div>a</div>
109
- <div>b</div>
117
+ <DragProbe label="a" />
118
+ <DragProbe label="b" />
110
119
  </Workspace>,
111
120
  );
112
- expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(0);
121
+ expect(screen.getByTestId('drag-a')).toHaveTextContent('static');
122
+ expect(screen.getByTestId('drag-b')).toHaveTextContent('static');
113
123
  });
114
124
  });
@@ -11,13 +11,13 @@ import { asNodeId, createNode, gridStrategy, type NodeId, Store } from 'windease
11
11
  import {
12
12
  type ChromeMap,
13
13
  Container,
14
- DragHandle,
15
14
  DragProvider,
16
15
  Provider,
17
16
  StrategyRegistryProvider,
18
17
  } from 'windease/react';
19
18
 
20
19
  import { useSurfaceOptional } from '../surface/useSurfaceTile';
20
+ import { TrialDragContext } from '../trial/TrialDragContext';
21
21
 
22
22
  const ZONE_ID = asNodeId('lk-workspace');
23
23
  const STRATEGIES = { grid: gridStrategy as never };
@@ -175,14 +175,11 @@ export function Workspace({
175
175
  [KIND]: ({ node }) => {
176
176
  const content = byId.get(node.id) ?? null;
177
177
  if (!reorderable) return content;
178
- // A tile is full of controls, so the whole thing can't be the handle.
179
178
  return (
180
- <div className="lk-trial-tile">
181
- <DragHandle nodeId={node.id} className="lk-trial-tile__grip">
182
- <span className="lk-trial-tile__grip-dots" aria-hidden="true" />
183
- </DragHandle>
184
- <div className="lk-trial-tile__body">{content}</div>
185
- </div>
179
+ // No grip strip: the trial's own title bar is the drag surface.
180
+ <TrialDragContext.Provider value={{ nodeId: node.id }}>
181
+ <div className="lk-trial-tile">{content}</div>
182
+ </TrialDragContext.Provider>
186
183
  );
187
184
  },
188
185
  };