@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,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createLabStore } from './store';
3
+
4
+ function store() {
5
+ return createLabStore({ storageKey: 'test', storage: { read: () => null, write: () => {} } });
6
+ }
7
+
8
+ const record = { id: 't1', instrumentName: 'X', config: {}, state: {}, view: {} };
9
+
10
+ describe('the tool slot', () => {
11
+ it('starts empty at both levels', () => {
12
+ const s = store();
13
+ expect(s.getState().activeToolId).toBeNull();
14
+ });
15
+
16
+ it('sets the lab slot', () => {
17
+ const s = store();
18
+ s.getState().setLabTool('brush');
19
+ expect(s.getState().activeToolId).toBe('brush');
20
+ });
21
+
22
+ it('sets a trial slot without touching the lab one', () => {
23
+ const s = store();
24
+ s.getState().addTrial(record);
25
+ s.getState().setLabTool('brush');
26
+ s.getState().setTrialTool('t1', 'eraser');
27
+ expect(s.getState().activeToolId).toBe('brush');
28
+ expect(s.getState().trials[0].activeToolId).toBe('eraser');
29
+ });
30
+
31
+ it('leaves a trial slot undefined until it is set', () => {
32
+ const s = store();
33
+ s.getState().addTrial(record);
34
+ expect(s.getState().trials[0].activeToolId).toBeUndefined();
35
+ });
36
+ });
@@ -15,6 +15,8 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
15
15
  /** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
16
16
  * but never read into. A 3D lab puts an orbit here and keeps all three. */
17
17
  view: TV;
18
+ /** This trial's own tool slot. Undefined means it reads the lab's. */
19
+ activeToolId?: string | null;
18
20
  undoStack: UndoStack;
19
21
  }
20
22
 
@@ -39,6 +41,8 @@ export interface LabStoreState {
39
41
  trials: TrialRecord[];
40
42
  savedSnapshots: SavedSnapshot[];
41
43
  mode: LabMode;
44
+ /** The lab's tool slot — what a trial with no slot of its own resolves to. */
45
+ activeToolId: string | null;
42
46
  /** Per-trial tile extents, keyed by trial id. Opaque here — the
43
47
  * shape belongs to whatever lays the trials out. */
44
48
  layout: Record<string, unknown>;
package/src/styles.less CHANGED
@@ -4,11 +4,17 @@
4
4
  @import './lab/LabShell.less';
5
5
  @import './primitives/Toolbar.less';
6
6
  @import './primitives/Sidebar.less';
7
+ @import './primitives/ZoomControl.less';
7
8
  @import './primitives/StatusBar.less';
9
+ @import './primitives/JobProgress.less';
8
10
  @import './primitives/FpsMeter.less';
9
11
  @import './primitives/ScaleIndicator.less';
12
+ @import './primitives/Legend.less';
13
+ @import './primitives/FloatingPanel.less';
10
14
  @import './lab/Lab.less';
11
15
  @import './trial/Trial.less';
16
+ @import './chrome/regions/ViewportRegion.less';
17
+ @import './chrome/regions/PaletteRegion.less';
12
18
  @import './controls/ControlPanel.less';
13
19
  @import './canvas/CanvasStack.less';
14
20
  @import './layers/LayerList.less';
@@ -1,3 +1,4 @@
1
+ import { useVisibleRaf } from '@weasel-js/core';
1
2
  import { useCallback, useEffect, useRef } from 'react';
2
3
  import { composeRects, rectsEqual } from './composeRects';
3
4
  import type { Box, Rect } from './rect';
@@ -35,7 +36,6 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
35
36
  const rects = useRef(new Map<string, Rect>());
36
37
  const dirty = useRef(new Set<string>());
37
38
  const needsMeasure = useRef(true);
38
- const raf = useRef(0);
39
39
  const observer = useRef<ResizeObserver | null>(null);
40
40
  const lastDpr = useRef(0);
41
41
 
@@ -58,10 +58,8 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
58
58
  return changed;
59
59
  }, []);
60
60
 
61
- const schedule = useCallback(() => {
62
- if (raf.current) return;
63
- raf.current = requestAnimationFrame(() => {
64
- raf.current = 0;
61
+ const frameLoop = useVisibleRaf(
62
+ () => {
65
63
  const el = container.current;
66
64
  if (!el) {
67
65
  dirty.current.clear();
@@ -85,8 +83,15 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
85
83
  size: { width: box.width, height: box.height },
86
84
  });
87
85
  dirty.current.clear();
88
- });
89
- }, [measure]);
86
+ },
87
+ // The host attaches its container through `containerRef`, which may land
88
+ // well after this hook's first effect; the gate re-resolves it per request.
89
+ { target: () => container.current },
90
+ );
91
+
92
+ const schedule = useCallback(() => {
93
+ frameLoop.request();
94
+ }, [frameLoop]);
90
95
 
91
96
  const invalidate = useCallback(
92
97
  (id: string) => {
@@ -148,10 +153,9 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
148
153
  return () => {
149
154
  ro.disconnect();
150
155
  observer.current = null;
151
- if (raf.current) cancelAnimationFrame(raf.current);
152
- raf.current = 0;
156
+ frameLoop.cancel();
153
157
  };
154
- }, [schedule]);
158
+ }, [schedule, frameLoop]);
155
159
 
156
160
  return { invalidate, invalidateAll, invalidateRects, registerTile, containerRef };
157
161
  }
package/src/test-setup.ts CHANGED
@@ -31,6 +31,32 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
31
31
  } as unknown as typeof ResizeObserver;
32
32
  }
33
33
 
34
+ // jsdom ships no PointerEvent, so `fireEvent.pointerDown` builds a bare Event
35
+ // and drops clientX/clientY — a drag handler then reads `undefined` and computes
36
+ // NaN deltas, which the DOM rejects as invalid CSS rather than reporting.
37
+ // MouseEvent already carries the coordinate fields.
38
+ if (typeof globalThis.PointerEvent === 'undefined') {
39
+ class PointerEventPolyfill extends MouseEvent {
40
+ readonly pointerId: number;
41
+ readonly pointerType: string;
42
+ readonly isPrimary: boolean;
43
+ constructor(type: string, init: PointerEventInit = {}) {
44
+ super(type, init);
45
+ this.pointerId = init.pointerId ?? 1;
46
+ this.pointerType = init.pointerType ?? 'mouse';
47
+ this.isPrimary = init.isPrimary ?? true;
48
+ }
49
+ }
50
+ globalThis.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent;
51
+ }
52
+
53
+ // jsdom implements neither side of pointer capture.
54
+ if (typeof Element.prototype.setPointerCapture === 'undefined') {
55
+ Element.prototype.setPointerCapture = () => {};
56
+ Element.prototype.releasePointerCapture = () => {};
57
+ Element.prototype.hasPointerCapture = () => false;
58
+ }
59
+
34
60
  afterEach(() => {
35
61
  cleanup();
36
62
  });
@@ -16,6 +16,10 @@
16
16
 
17
17
  // Resets and global element defaults.
18
18
  .lk-root {
19
+ // labkit's own vocabulary over the theme's raw scale — named for the thing
20
+ // rather than the step, so "the workspace is too tight" has one place to go.
21
+ --lk-workspace-pad: var(--wzl-space-sm);
22
+
19
23
  font-family: var(--wzl-font-ui);
20
24
  font-size: var(--wzl-font-size);
21
25
  font-weight: var(--wzl-font-weight-light);
@@ -26,8 +30,15 @@
26
30
  *, *::before, *::after {
27
31
  box-sizing: border-box;
28
32
  }
33
+ }
29
34
 
30
- button {
35
+ // Defaults for labkit's own bare markup. `.lk-root` is inside :where() too, so
36
+ // the whole selector scores (0,0,0): a weasel-ui module class beats it on
37
+ // specificity instead of on which stylesheet happened to be injected last.
38
+ // Deliberate overrides of a weasel-ui component go the other way — see the
39
+ // `.lk-root` prefix convention below.
40
+ :where(.lk-root) {
41
+ :where(button) {
31
42
  font: inherit;
32
43
  color: inherit;
33
44
  background: var(--wzl-surface-raised);
@@ -55,11 +66,17 @@
55
66
  }
56
67
  }
57
68
 
58
- input[type="range"] {
69
+ :where(input[type="range"]) {
59
70
  accent-color: var(--wzl-accent);
60
71
  }
61
72
  }
62
73
 
74
+ // The `.lk-root` prefix convention: a labkit class handed to a weasel-ui
75
+ // component through `className` lands beside that component's own CSS-module
76
+ // class at equal specificity, so injection order — not intent — decides the
77
+ // winner. Prefixing with `.lk-root` scores (0,2,0) and settles it. Use it only
78
+ // where labkit means to override the component; plain `.lk-*` elsewhere.
79
+
63
80
  // Cosmic background applied to the Lab root. The starscape sits as a fixed
64
81
  // pseudo-element so panning the trial doesn't shift the field of stars.
65
82
  .lk-lab {
@@ -28,7 +28,9 @@
28
28
  "fg-subtle": { "$value": "#5a5a78" },
29
29
  "accent": { "$value": "#b08adb" },
30
30
  "accent-hover": { "$value": "#c6a3ee" },
31
- "focus-ring": { "$value": "rgba(176, 138, 219, 0.45)" }
31
+ "focus-ring": { "$value": "rgba(176, 138, 219, 0.45)" },
32
+ "shadow": { "$value": "rgba(0, 0, 0, 0.7)" },
33
+ "border-raised": { "$value": "rgba(255, 255, 255, 0.36)" }
32
34
  },
33
35
  "gradient": {
34
36
  "$type": "gradient",
@@ -50,7 +52,9 @@
50
52
  "fg-subtle": { "$value": "#a89b85" },
51
53
  "accent": { "$value": "#a86f3c" },
52
54
  "accent-hover": { "$value": "#c2854f" },
53
- "focus-ring": { "$value": "rgba(168, 111, 60, 0.35)" }
55
+ "focus-ring": { "$value": "rgba(168, 111, 60, 0.35)" },
56
+ "shadow": { "$value": "rgba(80, 60, 40, 0.18)" },
57
+ "border-raised": { "$value": "#9e8b71" }
54
58
  }
55
59
  }
56
60
  }
@@ -0,0 +1,24 @@
1
+ import type { IconComponent } from '../chrome/types';
2
+
3
+ /**
4
+ * A tool a trial can be in. labkit's own — core's `ToolsApi` carries hotkey
5
+ * slots, ambient tools, eligibility tiers and canvas overlay layers, all bound
6
+ * to the gesture dispatcher, and a labkit instrument is an arbitrary canvas or
7
+ * DOM tree rather than a weasel scene.
8
+ */
9
+ export interface TrialTool {
10
+ id: string;
11
+ label: string;
12
+ icon: IconComponent;
13
+ /** Shown in the tooltip. Not bound here — the instrument owns its keymap. */
14
+ shortcut?: string;
15
+ /** Presentation grouping in the palette. Ungrouped tools sort after grouped. */
16
+ group?: string;
17
+ }
18
+
19
+ /** What an instrument declares to get a palette region. */
20
+ export interface ToolCapability {
21
+ tools: TrialTool[];
22
+ /** Which tool a fresh trial starts in. Defaults to the first. */
23
+ initial?: string;
24
+ }
@@ -0,0 +1,113 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { f } from '../config/builder';
4
+ import type { ConfigRule, ControlRenderer } from '../config/types';
5
+ import { defineInstrument } from '../instrument/defineInstrument';
6
+ import { Lab } from '../lab/Lab';
7
+
8
+ const schemaInstrument = defineInstrument({
9
+ name: 'Schema',
10
+ config: f.schema({
11
+ showGrid: f.boolean(true),
12
+ cellSize: f.number(20).range(5, 80).step(5).label('Grid spacing'),
13
+ }),
14
+ initialState: () => ({}),
15
+ render: () => null,
16
+ });
17
+
18
+ const legacyInstrument = defineInstrument({
19
+ name: 'Legacy',
20
+ defaultConfig: () => ({ showGrid: true }),
21
+ configSchema: () => [
22
+ { type: 'checkbox' as const, key: 'showGrid', label: 'Show grid', default: true },
23
+ ],
24
+ initialState: () => ({}),
25
+ render: () => null,
26
+ });
27
+
28
+ describe('a trial renders its instrument config', () => {
29
+ it('renders a builder schema into the settings sidebar', () => {
30
+ render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
31
+ expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
32
+ expect(screen.getByText('Grid spacing')).toBeInTheDocument();
33
+ });
34
+
35
+ it('renders a legacy ConfigField list through the same path', () => {
36
+ render(<Lab instruments={[legacyInstrument]} defaultInstrument="Legacy" />);
37
+ expect(screen.getByLabelText('Show grid')).toBeInTheDocument();
38
+ });
39
+
40
+ it('writes a config change back to the trial', () => {
41
+ render(<Lab instruments={[schemaInstrument]} defaultInstrument="Schema" />);
42
+ const checkbox = screen.getByLabelText('Show grid') as HTMLInputElement;
43
+ expect(checkbox.checked).toBe(true);
44
+ fireEvent.click(checkbox);
45
+ expect((screen.getByLabelText('Show grid') as HTMLInputElement).checked).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe('lab-wide config seams', () => {
50
+ it('applies a lab-wide rule before labkit inference', () => {
51
+ const inst = defineInstrument({
52
+ name: 'Ruled',
53
+ config: f.schema({ tintColor: f.value('#ffffff') }),
54
+ initialState: () => ({}),
55
+ render: () => null,
56
+ });
57
+ const colorByName: ConfigRule = (ctx) =>
58
+ ctx.key.endsWith('Color') ? { kind: 'color' } : null;
59
+ render(
60
+ <Lab instruments={[inst]} defaultInstrument="Ruled" configRules={[colorByName]} />,
61
+ );
62
+ expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'color');
63
+ });
64
+
65
+ it('without the rule the same leaf falls back to a text input', () => {
66
+ const inst = defineInstrument({
67
+ name: 'Unruled',
68
+ config: f.schema({ tintColor: f.value('#ffffff') }),
69
+ initialState: () => ({}),
70
+ render: () => null,
71
+ });
72
+ render(<Lab instruments={[inst]} defaultInstrument="Unruled" />);
73
+ expect(screen.getByLabelText('Tint color')).toHaveAttribute('type', 'text');
74
+ });
75
+
76
+ it('supplies a lab-wide control for a kind labkit does not ship', () => {
77
+ const inst = defineInstrument({
78
+ name: 'Custom',
79
+ config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
80
+ initialState: () => ({}),
81
+ render: () => null,
82
+ });
83
+ const vector2: ControlRenderer = (ctx) => (
84
+ <span>vec:{String((ctx.value as { x: number }).x)}</span>
85
+ );
86
+ render(
87
+ <Lab instruments={[inst]} defaultInstrument="Custom" controls={{ vector2 }} />,
88
+ );
89
+ expect(screen.getByText('vec:3')).toBeInTheDocument();
90
+ });
91
+
92
+ it('shows the placeholder when no control is supplied for that kind', () => {
93
+ const inst = defineInstrument({
94
+ name: 'Unwired',
95
+ config: f.schema({ offset: f.custom('vector2', { x: 3 }) }),
96
+ initialState: () => ({}),
97
+ render: () => null,
98
+ });
99
+ render(<Lab instruments={[inst]} defaultInstrument="Unwired" />);
100
+ expect(screen.getByText(/vector2/)).toBeInTheDocument();
101
+ });
102
+
103
+ it('omits the settings section for an instrument with no config', () => {
104
+ const inst = defineInstrument({
105
+ name: 'Bare',
106
+ defaultConfig: () => ({}),
107
+ initialState: () => ({}),
108
+ render: () => null,
109
+ });
110
+ render(<Lab instruments={[inst]} defaultInstrument="Bare" />);
111
+ expect(screen.queryByText('Settings')).not.toBeInTheDocument();
112
+ });
113
+ });
@@ -1,4 +1,4 @@
1
- import { render, waitFor } from '@testing-library/react';
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -13,22 +13,22 @@ interface C {
13
13
  }
14
14
 
15
15
  function makeInstrument(name: string, n: number, delay: number) {
16
- return defineInstrument<S, C>({
16
+ return defineInstrument<S, C, number>({
17
17
  name,
18
18
  defaultConfig: () => ({ n }),
19
19
  initialState: () => ({ items: [] }),
20
20
  render: () => null,
21
21
  job: {
22
22
  auto: true,
23
- run: async function* ({ config, signal }): AsyncGenerator<JobEvent<never>> {
23
+ run: async function* ({ config, signal }): AsyncGenerator<JobEvent<number>> {
24
24
  yield { kind: 'total', total: config.n };
25
25
  for (let i = 0; i < config.n; i++) {
26
26
  await new Promise((r) => setTimeout(r, delay));
27
27
  if (signal.aborted) return;
28
- yield { kind: 'item', item: i as never };
28
+ yield { kind: 'item', item: i };
29
29
  }
30
30
  },
31
- onItem: (item, state) => ({ items: [...state.items, item as number] }),
31
+ onItem: (item, state) => ({ items: [...state.items, item] }),
32
32
  },
33
33
  });
34
34
  }
@@ -37,25 +37,23 @@ describe('a trial whose instrument declares a job', () => {
37
37
  it('shows progress in the trial chrome', async () => {
38
38
  const fast = makeInstrument('fast', 3, 1);
39
39
  render(<Lab instruments={[fast]} defaultInstrument="fast" storage={null} />);
40
+ const bar = await waitFor(() => screen.getByRole('progressbar', { name: /job progress/i }));
40
41
  await waitFor(() => {
41
- expect(document.querySelector('.lk-trial__job')).toBeInTheDocument();
42
- });
43
- await waitFor(() => {
44
- expect(document.querySelector('.lk-trial__job-count')?.textContent).toMatch(/3\s*\/\s*3/);
42
+ // A job that reported a total drives a determinate bar, so the count and
43
+ // the accessible value have to agree.
44
+ expect(bar).toHaveAttribute('aria-valuemax', '3');
45
+ expect(bar).toHaveAttribute('aria-valuenow', '3');
45
46
  });
47
+ expect(document.querySelector('.lk-job__count')?.textContent).toMatch(/3\s*\/\s*3/);
46
48
  });
47
49
 
48
50
  it('offers a cancel control while running, and drops it once cancelled', async () => {
49
51
  const slow = makeInstrument('slow', 40, 20);
50
52
  render(<Lab instruments={[slow]} defaultInstrument="slow" storage={null} />);
51
- const cancel = await waitFor(() => {
52
- const el = document.querySelector('.lk-trial__job-cancel');
53
- expect(el).toBeInTheDocument();
54
- return el as HTMLElement;
55
- });
53
+ const cancel = await waitFor(() => screen.getByRole('button', { name: /cancel/i }));
56
54
  await userEvent.click(cancel);
57
55
  await waitFor(() => {
58
- expect(document.querySelector('.lk-trial__job-cancel')).not.toBeInTheDocument();
56
+ expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
59
57
  });
60
58
  });
61
59
 
@@ -1,10 +1,17 @@
1
1
  .lk-trial {
2
2
  display: flex;
3
+ // Click-focusable so the trial's own shortcuts reach it; the trial is not a
4
+ // control, so it draws no focus ring of its own.
5
+ outline: none;
3
6
  flex-direction: column;
4
7
  height: 100%;
5
- background: var(--wzl-surface);
6
- border: 1px solid var(--wzl-border);
8
+ // The lab paints --wzl-surface, so a trial on the same token was separated
9
+ // from its own workspace by nothing but a hairline. Raised reads as a panel,
10
+ // and in dark it is translucent, so the backdrop still shows through.
11
+ background: var(--wzl-surface-raised);
12
+ border: var(--wzl-border-w) solid var(--wzl-border-raised);
7
13
  border-radius: var(--wzl-radius-md);
14
+ box-shadow: 0 1px 3px var(--wzl-shadow);
8
15
  overflow: hidden;
9
16
 
10
17
  &__body {
@@ -15,21 +22,52 @@
15
22
 
16
23
  &__sidebar {
17
24
  flex-shrink: 0;
25
+ display: flex;
26
+ flex-direction: column;
27
+ min-height: 0;
28
+ // Sections floor at their content height, so without this the last ones
29
+ // are cut rather than reachable.
30
+ overflow-y: auto;
18
31
  }
19
32
 
33
+ // An instrument's rendered output is dropped in here directly, so the
34
+ // content well is what makes `height: 100%` mean anything to it. Without
35
+ // the column context and the stretched child, anything a lab returns
36
+ // collapses to its intrinsic height and the trial looks empty below it.
20
37
  &__content {
38
+ // The viewport region anchors against this well.
39
+ position: relative;
21
40
  flex: 1;
41
+ min-width: 0;
42
+ min-height: 0;
43
+ display: flex;
44
+ flex-direction: column;
22
45
  overflow: auto;
46
+
47
+ > * {
48
+ flex: 1 1 auto;
49
+ min-height: 0;
50
+ }
51
+
52
+ // An instrument owns its own interior; the frame contributes chrome, not
53
+ // inset. `--flush` is now only about scrolling.
54
+ &--flush {
55
+ overflow: hidden;
56
+ }
57
+ }
58
+
59
+ &__canvas-host {
60
+ position: relative;
61
+ width: 100%;
62
+ height: 100%;
23
63
  }
24
64
 
25
65
  &__toolbar {
26
66
  flex-shrink: 0;
27
- border-bottom: 1px solid var(--wzl-line-subtle);
28
67
  }
29
68
 
30
69
  &__status {
31
70
  flex-shrink: 0;
32
- border-top: 1px solid var(--wzl-line-subtle);
33
71
  }
34
72
 
35
73
  &--unknown {
@@ -51,20 +89,87 @@
51
89
  font-size: var(--wzl-font-size-sm);
52
90
  }
53
91
 
92
+ // Same `width: 100%` default as the header's Select — pinned so it cannot
93
+ // absorb the toolbar's slack. Placeholder-only, so it need only fit "Load…".
94
+ .lk-root .lk-toolbar__load-select {
95
+ width: 88px;
96
+ }
97
+
54
98
  .lk-sidebar__placeholder {
55
99
  padding: var(--wzl-space-sm);
56
100
  color: var(--wzl-fg-muted);
57
101
  font-size: var(--wzl-font-size-sm);
58
102
  }
59
103
 
60
- .lk-trial__job {
104
+
105
+ .lk-trial__titlebar {
61
106
  display: flex;
62
- gap: 8px;
63
107
  align-items: center;
64
- font-size: 0.75rem;
108
+ gap: var(--wzl-space-sm);
109
+ flex-shrink: 0;
110
+ padding: 0 var(--wzl-space-sm);
111
+ min-height: calc(var(--wzl-font-size-sm) * var(--wzl-leading) + var(--wzl-space-xs));
112
+ background: var(--wzl-surface-sunken);
113
+ border-bottom: var(--wzl-border-w) solid var(--wzl-border);
114
+ border-radius: var(--wzl-radius-md) var(--wzl-radius-md) 0 0;
115
+
116
+ // The bar is the drag surface, so it carries the cursor rather than an
117
+ // affordance of its own.
118
+ &--draggable {
119
+ cursor: grab;
120
+ touch-action: none;
121
+
122
+ &:active { cursor: grabbing; }
123
+ }
65
124
  }
66
125
 
67
- .lk-trial__job-failures,
68
- .lk-trial__job-error {
69
- color: var(--wsl-color-danger, #c0392b);
126
+ .lk-trial__titlebar-actions {
127
+ display: inline-flex;
128
+ align-items: center;
129
+ gap: 2px;
130
+ margin-left: auto;
131
+ }
132
+
133
+ // Prefixed with `.lk-root` to outrank base.less's `:where(button)` default,
134
+ // which would give a 12px glyph a 24px chrome box on the title bar.
135
+ .lk-root .lk-titlebar-button {
136
+ display: inline-flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ width: 18px;
140
+ height: 18px;
141
+ padding: 0;
142
+ border: 0;
143
+ border-radius: var(--wzl-radius-sm);
144
+ background: transparent;
145
+ backdrop-filter: none;
146
+ -webkit-backdrop-filter: none;
147
+ // Matches .lk-trial__title — the X reads as part of the bar's own type,
148
+ // not as chrome sitting on top of it.
149
+ color: var(--wzl-fg-muted);
150
+ cursor: pointer;
151
+
152
+ &:hover {
153
+ background: var(--wzl-surface-hover);
154
+ color: var(--wzl-fg);
155
+ }
156
+
157
+ &:disabled {
158
+ opacity: 0.4;
159
+ cursor: default;
160
+ }
161
+
162
+ &--danger:hover:not(:disabled) {
163
+ background: color-mix(in srgb, var(--wzl-danger) 15%, transparent);
164
+ color: var(--wzl-danger);
165
+ }
166
+ }
167
+
168
+ .lk-trial__title {
169
+ font-size: var(--wzl-font-size-sm);
170
+ font-weight: var(--wzl-font-weight-bold);
171
+ color: var(--wzl-fg-muted);
172
+ white-space: nowrap;
173
+ overflow: hidden;
174
+ text-overflow: ellipsis;
70
175
  }
@@ -59,6 +59,7 @@ function Harness() {
59
59
  setView: () => {},
60
60
  zoom: 1,
61
61
  setZoom: () => {},
62
+ activeToolId: null,
62
63
  },
63
64
  emit: () => {},
64
65
  })}