@weasel-js/labkit 1.1.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 (226) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
  3. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  4. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  5. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  6. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  7. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  8. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
  9. package/dist/canvas/index.d.ts +8 -3
  10. package/dist/canvas/index.js +2 -1
  11. package/dist/chrome/index.d.ts +86 -0
  12. package/dist/chrome/index.js +8 -0
  13. package/dist/chrome/index.js.map +1 -0
  14. package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-HFDVGF4X.js +170 -0
  17. package/dist/chunk-HFDVGF4X.js.map +1 -0
  18. package/dist/chunk-LN6JDUGB.js +106 -0
  19. package/dist/chunk-LN6JDUGB.js.map +1 -0
  20. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  21. package/dist/chunk-NS54R52R.js.map +1 -0
  22. package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
  23. package/dist/chunk-O2BULFHX.js.map +1 -0
  24. package/dist/chunk-O7NVSCLN.js +266 -0
  25. package/dist/chunk-O7NVSCLN.js.map +1 -0
  26. package/dist/chunk-PO6L3NSH.js +366 -0
  27. package/dist/chunk-PO6L3NSH.js.map +1 -0
  28. package/dist/chunk-PQJ5B2U2.js +27 -0
  29. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  30. package/dist/chunk-RVNN4CHQ.js +212 -0
  31. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  32. package/dist/chunk-TLGRYALP.js +6151 -0
  33. package/dist/chunk-TLGRYALP.js.map +1 -0
  34. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  35. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  36. package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
  37. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  38. package/dist/chunk-ZZAYVX4X.js +505 -0
  39. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  40. package/dist/controls/index.d.ts +19 -5
  41. package/dist/controls/index.js +3 -1
  42. package/dist/dragdrop/index.d.ts +5 -2
  43. package/dist/index.d.ts +307 -107
  44. package/dist/index.js +797 -540
  45. package/dist/index.js.map +1 -1
  46. package/dist/job/index.d.ts +13 -0
  47. package/dist/job/index.js +3 -0
  48. package/dist/job/index.js.map +1 -0
  49. package/dist/layers/index.d.ts +6 -3
  50. package/dist/layers/index.js +2 -1
  51. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  52. package/dist/passthrough/weasel-canvas.js +1 -1
  53. package/dist/passthrough/weasel-ui.d.ts +3804 -3661
  54. package/dist/passthrough/weasel-ui.js +2 -2
  55. package/dist/primitives/index.d.ts +102 -8
  56. package/dist/primitives/index.js +4 -1
  57. package/dist/state/index.d.ts +3 -2
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +543 -144
  60. package/dist/surface/index.d.ts +77 -0
  61. package/dist/surface/index.js +4 -0
  62. package/dist/surface/index.js.map +1 -0
  63. package/dist/ui/layers/index.js +4 -3
  64. package/dist/undo/index.d.ts +5 -2
  65. package/package.json +24 -7
  66. package/src/canvas/AGENTS.md +12 -0
  67. package/src/canvas/CanvasStack.tsx +6 -2
  68. package/src/canvas/useLayerScheduler.ts +22 -15
  69. package/src/canvas/useOrbit.test.ts +71 -0
  70. package/src/canvas/useOrbit.ts +149 -0
  71. package/src/canvas/usePanZoom.test.ts +80 -0
  72. package/src/canvas/usePanZoom.ts +16 -2
  73. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  74. package/src/chrome/builtins.test.ts +153 -0
  75. package/src/chrome/builtins.tsx +205 -0
  76. package/src/chrome/index.ts +25 -0
  77. package/src/chrome/merge.test.ts +51 -0
  78. package/src/chrome/merge.ts +52 -0
  79. package/src/chrome/regions/PaletteRegion.less +14 -0
  80. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  81. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  82. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  83. package/src/chrome/regions/StatusRegion.tsx +31 -0
  84. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  85. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  87. package/src/chrome/regions/ViewportRegion.less +42 -0
  88. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  89. package/src/chrome/regions/regions.test.tsx +81 -0
  90. package/src/chrome/types.ts +127 -0
  91. package/src/config/builder.test.ts +87 -0
  92. package/src/config/builder.ts +224 -0
  93. package/src/config/fromConfigField.test.ts +93 -0
  94. package/src/config/fromConfigField.ts +74 -0
  95. package/src/config/index.ts +22 -0
  96. package/src/config/resolve.test.ts +120 -0
  97. package/src/config/resolve.ts +67 -0
  98. package/src/config/rules.ts +68 -0
  99. package/src/config/types.ts +108 -0
  100. package/src/config/useConfigSchema.ts +30 -0
  101. package/src/config/visible.ts +20 -0
  102. package/src/controls/ControlPanel.less +4 -58
  103. package/src/controls/ControlPanel.stories.tsx +86 -0
  104. package/src/controls/ControlPanel.test.tsx +223 -10
  105. package/src/controls/ControlPanel.tsx +202 -203
  106. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  107. package/src/dragdrop/DragGhost.less +1 -1
  108. package/src/dragdrop/Palette.less +1 -1
  109. package/src/index.test.ts +18 -0
  110. package/src/index.ts +38 -3
  111. package/src/instrument/SineWave.smoke.test.tsx +9 -1
  112. package/src/instrument/defineInstrument.test.ts +38 -1
  113. package/src/instrument/defineInstrument.ts +31 -4
  114. package/src/instrument/index.ts +1 -0
  115. package/src/instrument/types.ts +38 -3
  116. package/src/instrument/validateConfigSchema.test.ts +13 -3
  117. package/src/instrument/validateConfigSchema.ts +7 -10
  118. package/src/job/index.ts +3 -0
  119. package/src/job/types.ts +47 -0
  120. package/src/job/useJob.test.tsx +210 -0
  121. package/src/job/useJob.ts +134 -0
  122. package/src/lab/Lab.chrome.test.tsx +65 -0
  123. package/src/lab/Lab.stories.tsx +1 -2
  124. package/src/lab/Lab.tsx +58 -15
  125. package/src/lab/LabContext.ts +6 -0
  126. package/src/lab/LabFullChrome.stories.tsx +308 -0
  127. package/src/lab/LabHeader.test.tsx +42 -0
  128. package/src/lab/LabHeader.tsx +57 -0
  129. package/src/lab/LabPalette.test.tsx +35 -0
  130. package/src/lab/LabPalette.tsx +34 -0
  131. package/src/lab/LabShell.less +50 -3
  132. package/src/lab/Workspace.less +1 -30
  133. package/src/lab/Workspace.surface.test.tsx +49 -0
  134. package/src/lab/Workspace.test.tsx +20 -10
  135. package/src/lab/Workspace.tsx +19 -9
  136. package/src/lab/index.ts +2 -0
  137. package/src/layers/AGENTS.md +1 -1
  138. package/src/layers/LayerList.less +31 -7
  139. package/src/layers/LayerList.tsx +17 -4
  140. package/src/passthrough/weasel-ui.ts +23 -0
  141. package/src/primitives/DragHandleGlyph.tsx +29 -0
  142. package/src/primitives/FloatingPanel.less +28 -0
  143. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  144. package/src/primitives/FloatingPanel.test.tsx +240 -0
  145. package/src/primitives/FloatingPanel.tsx +190 -0
  146. package/src/primitives/FpsMeter.test.tsx +44 -0
  147. package/src/primitives/FpsMeter.tsx +20 -9
  148. package/src/primitives/JobProgress.less +69 -0
  149. package/src/primitives/JobProgress.stories.tsx +48 -0
  150. package/src/primitives/JobProgress.tsx +65 -0
  151. package/src/primitives/Legend.less +47 -0
  152. package/src/primitives/Legend.stories.tsx +32 -0
  153. package/src/primitives/Legend.test.tsx +57 -0
  154. package/src/primitives/Legend.tsx +40 -0
  155. package/src/primitives/Sidebar.less +32 -2
  156. package/src/primitives/StatusBar.less +7 -0
  157. package/src/primitives/StatusBar.tsx +10 -3
  158. package/src/primitives/Toolbar.less +50 -3
  159. package/src/primitives/Toolbar.test.tsx +67 -1
  160. package/src/primitives/Toolbar.tsx +72 -6
  161. package/src/primitives/ZoomControl.less +31 -0
  162. package/src/primitives/ZoomControl.tsx +76 -0
  163. package/src/primitives/floating.entry.test.tsx +14 -0
  164. package/src/primitives/index.ts +10 -2
  165. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  166. package/src/primitives/useRovingTabIndex.ts +79 -0
  167. package/src/state/store.ts +16 -2
  168. package/src/state/toolSlot.test.ts +36 -0
  169. package/src/state/types.ts +8 -2
  170. package/src/state/view.test.ts +127 -0
  171. package/src/state/view.ts +18 -0
  172. package/src/styles.less +6 -0
  173. package/src/surface/AGENTS.md +64 -0
  174. package/src/surface/SurfaceContext.ts +5 -0
  175. package/src/surface/composeRects.test.ts +50 -0
  176. package/src/surface/composeRects.ts +19 -0
  177. package/src/surface/deviceRect.test.ts +40 -0
  178. package/src/surface/deviceRect.ts +19 -0
  179. package/src/surface/index.ts +7 -0
  180. package/src/surface/rect.ts +16 -0
  181. package/src/surface/useSurfaceTile.test.tsx +67 -0
  182. package/src/surface/useSurfaceTile.ts +32 -0
  183. package/src/surface/useTiledSurface.test.tsx +231 -0
  184. package/src/surface/useTiledSurface.ts +161 -0
  185. package/src/test-setup.ts +26 -0
  186. package/src/theme/base.less +19 -2
  187. package/src/theme/interstellar.tokens.json +6 -2
  188. package/src/tools/types.ts +24 -0
  189. package/src/trial/Trial.config.test.tsx +113 -0
  190. package/src/trial/Trial.job.test.tsx +73 -0
  191. package/src/trial/Trial.less +121 -4
  192. package/src/trial/Trial.stories.tsx +9 -1
  193. package/src/trial/Trial.test.tsx +67 -96
  194. package/src/trial/Trial.tsx +112 -45
  195. package/src/trial/TrialChrome.tsx +110 -64
  196. package/src/trial/TrialDragContext.ts +10 -0
  197. package/src/trial/TrialTitleBar.tsx +41 -0
  198. package/src/trial/index.ts +1 -15
  199. package/src/trial/trialChrome.borders.test.ts +38 -0
  200. package/src/ui/layers/LayerStack.less +9 -9
  201. package/src/ui/layers/LayerStack.tsx +1 -13
  202. package/src/ui/properties/CurveField.less +2 -2
  203. package/src/ui/properties/CurveField.test.tsx +11 -11
  204. package/src/ui/properties/PropertyGroup.less +2 -2
  205. package/src/ui/properties/PropertyPanel.less +55 -41
  206. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  207. package/src/ui/properties/storyLayouts.tsx +2 -2
  208. package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
  209. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  210. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  211. package/dist/chunk-574LJAV4.js.map +0 -1
  212. package/dist/chunk-5R2ATYPJ.js +0 -201
  213. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  214. package/dist/chunk-C6GJKPUI.js +0 -5986
  215. package/dist/chunk-C6GJKPUI.js.map +0 -1
  216. package/dist/chunk-DJLDIRFN.js.map +0 -1
  217. package/dist/chunk-NRKWVTVT.js.map +0 -1
  218. package/dist/chunk-PMAU3SEE.js +0 -126
  219. package/dist/chunk-PMAU3SEE.js.map +0 -1
  220. package/dist/chunk-SFL7NFKN.js.map +0 -1
  221. package/src/instrument/capabilityDetector.test.ts +0 -64
  222. package/src/instrument/capabilityDetector.ts +0 -20
  223. package/src/trial/DefaultSidebar.tsx +0 -29
  224. package/src/trial/DefaultStatusBar.tsx +0 -19
  225. package/src/trial/DefaultToolbar.tsx +0 -92
  226. package/src/trial/slotTypes.ts +0 -53
@@ -0,0 +1,65 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import type { TrialContribution } from '../chrome/types';
4
+ import type { Instrument } from '../instrument/types';
5
+ import { Lab } from './Lab';
6
+
7
+ const Glyph = () => <svg />;
8
+ const bare: Instrument = {
9
+ name: 'Bare',
10
+ defaultConfig: () => ({}),
11
+ initialState: () => ({}),
12
+ render: () => null,
13
+ };
14
+
15
+ /** React logs the error it re-throws from a failed render; the assertions are
16
+ * on the throw itself, so the console noise is not informative. */
17
+ function silenceRenderError(): void {
18
+ vi.spyOn(console, 'error').mockImplementation(() => {});
19
+ }
20
+
21
+ describe('<Lab> chrome', () => {
22
+ it('renders a consumer contribution', () => {
23
+ const extra: TrialContribution = {
24
+ id: 'export',
25
+ region: 'toolbar',
26
+ item: { icon: Glyph, label: 'Export', onActivate: () => {} },
27
+ };
28
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[extra]} />);
29
+ expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument();
30
+ });
31
+
32
+ it('suppresses a built-in by id', () => {
33
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['snapshot']} />);
34
+ expect(screen.queryByRole('button', { name: 'Save snapshot' })).toBeNull();
35
+ expect(screen.getByRole('button', { name: 'Clone trial' })).toBeInTheDocument();
36
+ });
37
+
38
+ it('throws when a consumer id collides with a built-in', () => {
39
+ silenceRenderError();
40
+ const clash: TrialContribution = {
41
+ id: 'clone',
42
+ region: 'toolbar',
43
+ item: { icon: Glyph, label: 'Mine', onActivate: () => {} },
44
+ };
45
+ expect(() =>
46
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" chrome={[clash]} />),
47
+ ).toThrow(/duplicate contribution id "clone"/);
48
+ });
49
+
50
+ it('throws when suppressing an id that is not there', () => {
51
+ silenceRenderError();
52
+ expect(() =>
53
+ render(<Lab title="T" instruments={[bare]} defaultInstrument="Bare" suppress={['nope']} />),
54
+ ).toThrow(/cannot suppress "nope"/);
55
+ });
56
+
57
+ it('renders an instrument-declared contribution', () => {
58
+ const withChrome: Instrument = {
59
+ ...bare,
60
+ chrome: [{ id: 'mine', region: 'status', item: { text: 'ready' } }],
61
+ };
62
+ render(<Lab title="T" instruments={[withChrome]} defaultInstrument="Bare" />);
63
+ expect(screen.getByText('ready')).toBeInTheDocument();
64
+ });
65
+ });
@@ -33,10 +33,9 @@ export const Default: Story = {
33
33
 
34
34
  function AddSecondTrial() {
35
35
  const ctx = useLabContext();
36
- // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
37
36
  useEffect(() => {
38
37
  if (ctx.trials.length < 2) ctx.addTrial('Stub');
39
- }, []);
38
+ }, [ctx]);
40
39
  return null;
41
40
  }
42
41
 
package/src/lab/Lab.tsx CHANGED
@@ -1,12 +1,15 @@
1
1
  import { ThemeProvider } from '@weasel-js/theme/react';
2
2
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef } from 'react';
3
3
  import { useStore } from 'zustand/react';
4
+ import type { TrialContribution } from '../chrome/types';
5
+ import type { ConfigRule, ControlRenderer } from '../config/types';
4
6
  import type { InstrumentList } from '../instrument/types';
5
7
  import { noneAdapter } from '../state/adapters';
6
8
  import { LabStoreContext } from '../state/context';
7
9
  import { createLabStore, type LabStore } from '../state/store';
8
10
  import type { LabMode, StorageAdapter, TrialRecord } from '../state/types';
9
11
  import { interstellarTheme } from '../theme/interstellar';
12
+ import type { TrialTool } from '../tools/types';
10
13
  import { Trial } from '../trial/Trial';
11
14
  import {
12
15
  addTrial as addTrialOp,
@@ -16,6 +19,8 @@ import {
16
19
  resetTrial as resetTrialOp,
17
20
  } from '../trial/trialOps';
18
21
  import { LabContext, type LabContextValue } from './LabContext';
22
+ import { LabHeader } from './LabHeader';
23
+ import { LabPalette } from './LabPalette';
19
24
  import { LabShell } from './LabShell';
20
25
  import { useResolvedMode } from './useSystemMode';
21
26
  import { type TrialLayout, Workspace } from './Workspace';
@@ -35,6 +40,22 @@ export interface LabProps {
35
40
  */
36
41
  nebula?: readonly string[];
37
42
  title?: string;
43
+ /** Rendered in the shell's footer, below the workspace. */
44
+ footer?: ReactNode;
45
+ /** Contributions added to every trial's chrome, after the instrument's own. */
46
+ chrome?: readonly TrialContribution[];
47
+ /** Built-in contribution ids to drop. Throws on an id that is not there. */
48
+ suppress?: readonly string[];
49
+ /** Tools offered lab-wide. A trial whose instrument declares none of its own
50
+ * reflects and writes this slot. */
51
+ tools?: readonly TrialTool[];
52
+ /** Rules run over every instrument's config leaves, before labkit's own
53
+ * inference — where a lab states a convention once instead of annotating
54
+ * each field. Memoize or hoist. */
55
+ configRules?: readonly ConfigRule[];
56
+ /** Control overrides and app-defined kinds for every trial's settings panel.
57
+ * Keys are config paths (checked first) or leaf kinds. Memoize or hoist. */
58
+ controls?: Record<string, ControlRenderer>;
38
59
  children?: ReactNode;
39
60
  }
40
61
 
@@ -88,6 +109,12 @@ export function Lab({
88
109
  mode,
89
110
  nebula,
90
111
  title,
112
+ footer,
113
+ chrome,
114
+ suppress,
115
+ tools,
116
+ configRules,
117
+ controls,
91
118
  children,
92
119
  }: LabProps) {
93
120
  if (process.env.NODE_ENV !== 'production' && instruments.length === 0) {
@@ -160,7 +187,8 @@ export function Lab({
160
187
  },
161
188
  savedSnapshots,
162
189
  saveSnapshot: (trialId, name) => {
163
- store.getState().saveSnapshot(trialId, name ?? `Save ${new Date().toLocaleString()}`);
190
+ const taken = store.getState().savedSnapshots.filter((s) => s.trialId === trialId).length;
191
+ store.getState().saveSnapshot(trialId, name ?? `Snapshot ${taken + 1}`);
164
192
  },
165
193
  loadSnapshot: (trialId, snapshotId) => {
166
194
  store.getState().loadSnapshot(snapshotId, trialId);
@@ -172,8 +200,10 @@ export function Lab({
172
200
  setMode: (m) => {
173
201
  store.getState().setMode(m);
174
202
  },
203
+ configRules,
204
+ controls,
175
205
  };
176
- }, [instruments, trials, savedSnapshots, modeValue, store]);
206
+ }, [instruments, trials, savedSnapshots, modeValue, store, configRules, controls]);
177
207
 
178
208
  // Only override the backdrop in dark, where there is one to override.
179
209
  // Setting a CSS custom property is the sanctioned use of inline style.
@@ -191,19 +221,32 @@ export function Lab({
191
221
  className="lk-lab"
192
222
  style={backdropStyle}
193
223
  >
194
- <LabShell title={title ?? 'Labkit'} mode={modeValue} header={children}>
195
- <Workspace
196
- ids={trials.map((w) => w.id)}
197
- resizable
198
- reorderable
199
- onReorder={(ids) => contextValue.reorderTrials(ids)}
200
- layout={layout as TrialLayout}
201
- onLayoutChange={(next) => store.getState().setLayout(next)}
202
- >
203
- {trials.map((w) => (
204
- <Trial key={w.id} id={w.id} />
205
- ))}
206
- </Workspace>
224
+ <LabShell
225
+ title={title ?? 'Labkit'}
226
+ mode={modeValue}
227
+ footer={footer}
228
+ header={
229
+ <>
230
+ <LabHeader />
231
+ {children}
232
+ </>
233
+ }
234
+ >
235
+ <div className="lk-lab__body">
236
+ {tools ? <LabPalette tools={tools} /> : null}
237
+ <Workspace
238
+ ids={trials.map((w) => w.id)}
239
+ resizable
240
+ reorderable
241
+ onReorder={(ids) => contextValue.reorderTrials(ids)}
242
+ layout={layout as TrialLayout}
243
+ onLayoutChange={(next) => store.getState().setLayout(next)}
244
+ >
245
+ {trials.map((w) => (
246
+ <Trial key={w.id} id={w.id} chrome={chrome} suppress={suppress} />
247
+ ))}
248
+ </Workspace>
249
+ </div>
207
250
  </LabShell>
208
251
  </ThemeProvider>
209
252
  </LabContext.Provider>
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext } from 'react';
2
+ import type { ConfigRule, ControlRenderer } from '../config/types';
2
3
  import type { InstrumentList } from '../instrument/types';
3
4
  import type { LabMode, SavedSnapshot, TrialRecord } from '../state/types';
4
5
 
@@ -19,6 +20,11 @@ export interface LabContextValue {
19
20
  deleteSnapshot: (snapshotId: string) => void;
20
21
  mode: LabMode;
21
22
  setMode: (m: LabMode) => void;
23
+ /** Lab-wide rules run over every instrument's config leaves, before
24
+ * labkit's own inference. */
25
+ configRules?: readonly ConfigRule[];
26
+ /** Lab-wide control overrides, keyed by config path or by leaf kind. */
27
+ controls?: Record<string, ControlRenderer>;
22
28
  }
23
29
 
24
30
  /** Context carrying the surrounding lab. Prefer `useLabContext`. */
@@ -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
+ });