@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.
- package/dist/_dts/{DrawCommand-C_XboUpz.d.ts → DrawCommand-DBB45NfN.d.ts} +196 -52
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/{useTrialState-BMNIx3Cy.d.ts → useTrialState-CsGMjhu9.d.ts} +5 -87
- package/dist/canvas/index.d.ts +7 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-VUU5UXHE.js → chunk-4TMMVIDM.js} +8 -5
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/{chunk-THBG7FQZ.js → chunk-HFDVGF4X.js} +15 -12
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-G5TJVQQT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-BOHF3PQO.js → chunk-Z6HJ5FEM.js} +8111 -5190
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +4 -2
- package/dist/index.d.ts +246 -106
- package/dist/index.js +673 -559
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +5 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3799 -3672
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +542 -153
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +4 -2
- package/package.json +16 -7
- package/src/canvas/AGENTS.md +4 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.ts +25 -3
- package/src/instrument/SineWave.smoke.test.tsx +8 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +27 -4
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +5 -8
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +11 -3
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +4 -0
- package/src/styles.less +6 -0
- package/src/surface/useTiledSurface.ts +14 -10
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +13 -15
- package/src/trial/Trial.less +115 -10
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +80 -39
- package/src/trial/TrialChrome.tsx +99 -80
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-JFAYj5Tv.d.ts +0 -144
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-73KA7WBO.js +0 -5992
- package/dist/chunk-73KA7WBO.js.map +0 -1
- package/dist/chunk-BOHF3PQO.js.map +0 -1
- package/dist/chunk-G5TJVQQT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/dist/chunk-THBG7FQZ.js.map +0 -1
- package/dist/chunk-VUU5UXHE.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- 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
|
+
}
|
package/src/lab/LabShell.less
CHANGED
|
@@ -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:
|
|
22
|
-
font-weight:
|
|
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(--
|
|
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
|
+
}
|
package/src/lab/Workspace.less
CHANGED
|
@@ -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
|
-
<
|
|
92
|
-
<
|
|
99
|
+
<DragProbe label="a" />
|
|
100
|
+
<DragProbe label="b" />
|
|
93
101
|
</Workspace>,
|
|
94
102
|
);
|
|
95
|
-
// The grid is controlled:
|
|
96
|
-
// itself. Order still comes from `ids`.
|
|
97
|
-
expect(
|
|
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('
|
|
106
|
-
|
|
114
|
+
test('offers no drag source unless reorderable', () => {
|
|
115
|
+
render(
|
|
107
116
|
<Workspace ids={['a', 'b']} viewport={VIEWPORT}>
|
|
108
|
-
<
|
|
109
|
-
<
|
|
117
|
+
<DragProbe label="a" />
|
|
118
|
+
<DragProbe label="b" />
|
|
110
119
|
</Workspace>,
|
|
111
120
|
);
|
|
112
|
-
expect(
|
|
121
|
+
expect(screen.getByTestId('drag-a')).toHaveTextContent('static');
|
|
122
|
+
expect(screen.getByTestId('drag-b')).toHaveTextContent('static');
|
|
113
123
|
});
|
|
114
124
|
});
|
package/src/lab/Workspace.tsx
CHANGED
|
@@ -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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
};
|