@weasel-js/labkit 1.6.0 → 1.7.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 (222) hide show
  1. package/dist/_dts/{CanvasStackContext-DQgcH1ny.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/FloatingPanel-7j0IcXwz.d.ts +30 -0
  3. package/dist/_dts/{LayerList.d-2c9yup5Y.d.ts → LayerList.d-DiuEArKq.d.ts} +4 -4
  4. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  5. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  6. package/dist/_dts/{Select.d-BchGMC2Y.d.ts → Select.d-Bensr_MT.d.ts} +15 -6
  7. package/dist/_dts/Stage-CCWfQQWM.d.ts +202 -0
  8. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  9. package/dist/_dts/{index-B0--8PEM.d.ts → index-CsYqUHD3.d.ts} +12 -13
  10. package/dist/_dts/{types-B7y8hX1D.d.ts → types-CRPSGFAp.d.ts} +33 -5
  11. package/dist/_dts/{frac-DTRq7Xpb.d.ts → types-CSm9jgol.d.ts} +700 -650
  12. package/dist/_dts/{useTrialState-CP4jfuKl.d.ts → useTrialState-CTZPgeZ7.d.ts} +2 -3
  13. package/dist/canvas/index.d.ts +40 -8
  14. package/dist/canvas/index.js +5 -3
  15. package/dist/chrome/index.d.ts +5 -6
  16. package/dist/chrome/index.js +9 -7
  17. package/dist/chunk-24W5VEJW.js +18 -0
  18. package/dist/chunk-24W5VEJW.js.map +1 -0
  19. package/dist/chunk-275JRRVD.js +728 -0
  20. package/dist/chunk-275JRRVD.js.map +1 -0
  21. package/dist/chunk-3RY6WL5T.js +38 -0
  22. package/dist/chunk-3RY6WL5T.js.map +1 -0
  23. package/dist/chunk-4LJSC7MB.js +143 -0
  24. package/dist/chunk-4LJSC7MB.js.map +1 -0
  25. package/dist/{chunk-3FI7Y4R4.js → chunk-7BO7HNGF.js} +5 -5
  26. package/dist/{chunk-3FI7Y4R4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  27. package/dist/chunk-AWQG7FCC.js +19 -0
  28. package/dist/chunk-AWQG7FCC.js.map +1 -0
  29. package/dist/chunk-CZZPXUOU.js +3 -0
  30. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  31. package/dist/{chunk-ZORVNOKX.js → chunk-FMK7UQ6V.js} +24 -145
  32. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  33. package/dist/{chunk-BVL7Y574.js → chunk-FZ7YFQN7.js} +26 -10
  34. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  35. package/dist/{chunk-USO6TERP.js → chunk-GOZLNM4L.js} +11 -9
  36. package/dist/chunk-GOZLNM4L.js.map +1 -0
  37. package/dist/{chunk-H5VIV7MQ.js → chunk-I5DUQ5HM.js} +88 -82
  38. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  39. package/dist/chunk-J7OWWTCW.js +143 -0
  40. package/dist/chunk-J7OWWTCW.js.map +1 -0
  41. package/dist/{chunk-H6ZAOWNE.js → chunk-JVBYV6TL.js} +3 -3
  42. package/dist/{chunk-H6ZAOWNE.js.map → chunk-JVBYV6TL.js.map} +1 -1
  43. package/dist/{chunk-VQ47KWD5.js → chunk-KMJ7RWD3.js} +18 -7
  44. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  45. package/dist/{chunk-ERFMC6WU.js → chunk-M7HP3FTK.js} +20 -11
  46. package/dist/chunk-M7HP3FTK.js.map +1 -0
  47. package/dist/chunk-MIM5R4ZT.js +14972 -0
  48. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  49. package/dist/{chunk-HFRE4QQV.js → chunk-T5IBSPAM.js} +96 -32
  50. package/dist/chunk-T5IBSPAM.js.map +1 -0
  51. package/dist/chunk-UNAZSLWE.js +294 -0
  52. package/dist/chunk-UNAZSLWE.js.map +1 -0
  53. package/dist/{chunk-RS3HRQWU.js → chunk-WPLAANH4.js} +4 -3
  54. package/dist/chunk-WPLAANH4.js.map +1 -0
  55. package/dist/{chunk-AWH3SRPW.js → chunk-Y23G2VZL.js} +5 -37
  56. package/dist/chunk-Y23G2VZL.js.map +1 -0
  57. package/dist/{chunk-MOLIUNVH.js → chunk-YT75VYRD.js} +4 -4
  58. package/dist/{chunk-MOLIUNVH.js.map → chunk-YT75VYRD.js.map} +1 -1
  59. package/dist/chunk-ZIFPHIWZ.js +302 -0
  60. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  61. package/dist/config/index.d.ts +48 -40
  62. package/dist/config/index.js +4 -5
  63. package/dist/controls/index.d.ts +91 -7
  64. package/dist/controls/index.js +5 -4
  65. package/dist/dragdrop/index.d.ts +9 -7
  66. package/dist/dragdrop/index.js +3 -2
  67. package/dist/index.d.ts +294 -82
  68. package/dist/index.js +425 -402
  69. package/dist/index.js.map +1 -1
  70. package/dist/layers/index.d.ts +4 -5
  71. package/dist/layers/index.js +2 -1
  72. package/dist/loupe/index.d.ts +20 -17
  73. package/dist/loupe/index.js +5 -3
  74. package/dist/overview/index.d.ts +56 -0
  75. package/dist/overview/index.js +289 -0
  76. package/dist/overview/index.js.map +1 -0
  77. package/dist/passthrough/weasel-ui.d.ts +239 -69
  78. package/dist/passthrough/weasel-ui.js +3 -2
  79. package/dist/primitives/index.d.ts +5 -204
  80. package/dist/primitives/index.js +6 -4
  81. package/dist/state/index.d.ts +8 -8
  82. package/dist/state/index.js +4 -5
  83. package/dist/state/index.js.map +1 -1
  84. package/dist/styles.css +152 -82
  85. package/dist/undo/index.d.ts +4 -4
  86. package/package.json +17 -12
  87. package/src/annotations/AnnotationOverlay.tsx +2 -2
  88. package/src/annotations/Annotations.less +0 -10
  89. package/src/annotations/MarkList.tsx +4 -8
  90. package/src/annotations/index.ts +1 -0
  91. package/src/annotations/paint.test.ts +3 -14
  92. package/src/annotations/paint.ts +3 -19
  93. package/src/annotations/store.test.ts +26 -0
  94. package/src/annotations/store.ts +25 -6
  95. package/src/annotations/svgNodes.test.ts +6 -9
  96. package/src/annotations/svgNodes.ts +3 -3
  97. package/src/annotations/toolMap.test.ts +37 -2
  98. package/src/annotations/toolMap.ts +13 -0
  99. package/src/annotations/types.ts +27 -0
  100. package/src/canvas/AGENTS.md +9 -10
  101. package/src/canvas/CameraInput.test.tsx +136 -0
  102. package/src/canvas/CameraInput.tsx +322 -0
  103. package/src/canvas/CanvasStack.tsx +47 -58
  104. package/src/canvas/LinkedCursor.less +16 -0
  105. package/src/canvas/LinkedCursor.tsx +54 -0
  106. package/src/canvas/Stage.test.tsx +33 -4
  107. package/src/canvas/Stage.tsx +47 -37
  108. package/src/canvas/cameraRegistry.test.ts +55 -0
  109. package/src/canvas/cameraRegistry.ts +86 -0
  110. package/src/canvas/cameraView.test.ts +60 -0
  111. package/src/canvas/cameraView.ts +61 -0
  112. package/src/canvas/cameraZoom.test.ts +46 -0
  113. package/src/canvas/cameraZoom.ts +24 -0
  114. package/src/canvas/index.ts +23 -2
  115. package/src/canvas/sharedScope.test.tsx +94 -0
  116. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  117. package/src/chrome/regions/PaletteRegion.less +0 -7
  118. package/src/chrome/regions/SidebarRegion.less +12 -2
  119. package/src/chrome/regions/SidebarRegion.test.tsx +9 -2
  120. package/src/config/builder.test.ts +2 -1
  121. package/src/config/builder.ts +15 -8
  122. package/src/config/entry.test.ts +3 -1
  123. package/src/config/fromConfigField.test.ts +14 -0
  124. package/src/config/fromConfigField.ts +19 -4
  125. package/src/config/index.ts +4 -1
  126. package/src/config/path.ts +2 -5
  127. package/src/config/resolve.ts +15 -1
  128. package/src/config/sectionTree.test.ts +12 -6
  129. package/src/config/sectionTree.ts +15 -5
  130. package/src/config/types.ts +34 -9
  131. package/src/controls/ControlPanel.less +1 -1
  132. package/src/controls/ControlPanel.stories.tsx +3 -2
  133. package/src/controls/ControlPanel.test.tsx +76 -13
  134. package/src/controls/ControlPanel.tsx +80 -11
  135. package/src/controls/inDialog.test.tsx +18 -0
  136. package/src/controls/inDialog.tsx +2 -10
  137. package/src/index.test.ts +1 -1
  138. package/src/index.ts +23 -5
  139. package/src/instrument/SineWave.smoke.test.tsx +2 -0
  140. package/src/instrument/types.ts +10 -1
  141. package/src/lab/Lab.nebula.test.tsx +11 -2
  142. package/src/lab/Lab.test.tsx +3 -1
  143. package/src/lab/Lab.tsx +87 -76
  144. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  145. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  146. package/src/lab/LabFullChrome.stories.tsx +1 -0
  147. package/src/lab/LabHeader.test.tsx +24 -14
  148. package/src/lab/LabHeader.tsx +11 -12
  149. package/src/lab/LabShell.less +8 -17
  150. package/src/lab/LabSwitcher.less +6 -6
  151. package/src/lab/LabZoom.test.tsx +223 -0
  152. package/src/lab/LabZoom.tsx +170 -0
  153. package/src/lab/index.ts +1 -0
  154. package/src/loupe/AGENTS.md +7 -6
  155. package/src/loupe/LoupeGestures.tsx +15 -10
  156. package/src/loupe/TrialLoupe.tsx +5 -5
  157. package/src/loupe/index.ts +1 -0
  158. package/src/loupe/loupeActions.ts +6 -2
  159. package/src/overview/AGENTS.md +38 -0
  160. package/src/overview/OverviewMarks.test.tsx +100 -0
  161. package/src/overview/OverviewMarks.tsx +93 -0
  162. package/src/overview/TrialOverview.less +53 -0
  163. package/src/overview/TrialOverview.stories.tsx +50 -0
  164. package/src/overview/TrialOverview.test.tsx +116 -0
  165. package/src/overview/TrialOverview.tsx +376 -0
  166. package/src/overview/index.ts +2 -0
  167. package/src/passthrough/weasel-ui.test.ts +2 -0
  168. package/src/passthrough/weasel-ui.ts +23 -4
  169. package/src/primitives/FloatingPanel.test.tsx +11 -0
  170. package/src/primitives/FloatingPanel.tsx +2 -5
  171. package/src/primitives/JobProgress.less +1 -1
  172. package/src/primitives/Legend.stories.tsx +1 -0
  173. package/src/primitives/Readout.browser.test.tsx +137 -0
  174. package/src/primitives/Readout.less +12 -0
  175. package/src/primitives/Readout.stories.tsx +58 -0
  176. package/src/primitives/Readout.test.tsx +93 -0
  177. package/src/primitives/Readout.tsx +56 -0
  178. package/src/primitives/index.ts +2 -0
  179. package/src/specimen/Specimen.stories.tsx +1 -0
  180. package/src/specimen/Specimen.tsx +21 -4
  181. package/src/state/Persistence.browser.test.tsx +47 -0
  182. package/src/state/labRecords.test.ts +4 -1
  183. package/src/state/store.test.ts +9 -5
  184. package/src/state/usePersistedState.test.tsx +1 -1
  185. package/src/state/useTrialState.test.tsx +1 -1
  186. package/src/styles.less +3 -0
  187. package/src/theme/Interstellar.stories.tsx +4 -6
  188. package/src/theme/base.less +21 -47
  189. package/src/theme/interstellar.test.ts +1 -1
  190. package/src/theme/interstellar.theme.json +10 -3
  191. package/src/tools/labTool.ts +6 -2
  192. package/src/trial/Trial.annotations.test.tsx +16 -0
  193. package/src/trial/Trial.less +0 -13
  194. package/src/trial/Trial.loupe.test.tsx +73 -1
  195. package/src/trial/Trial.stories.tsx +2 -0
  196. package/src/trial/Trial.tsx +23 -1
  197. package/src/trial/TrialChrome.tsx +21 -3
  198. package/src/trial/trialOps.test.ts +4 -4
  199. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  200. package/dist/_dts/inDialog-D5ZKGjiD.d.ts +0 -23
  201. package/dist/_dts/index-DINmCuLA.d.ts +0 -453
  202. package/dist/_dts/number.d-OeQYQoUO.d.ts +0 -28
  203. package/dist/_dts/usePanZoom-Bq73pwFe.d.ts +0 -117
  204. package/dist/chunk-2AYGEN57.js +0 -32
  205. package/dist/chunk-2AYGEN57.js.map +0 -1
  206. package/dist/chunk-AWH3SRPW.js.map +0 -1
  207. package/dist/chunk-BVL7Y574.js.map +0 -1
  208. package/dist/chunk-ERFMC6WU.js.map +0 -1
  209. package/dist/chunk-H5VIV7MQ.js.map +0 -1
  210. package/dist/chunk-HFRE4QQV.js.map +0 -1
  211. package/dist/chunk-RS3HRQWU.js.map +0 -1
  212. package/dist/chunk-SBXNQW4G.js +0 -3
  213. package/dist/chunk-TT5C5S6R.js +0 -368
  214. package/dist/chunk-TT5C5S6R.js.map +0 -1
  215. package/dist/chunk-USO6TERP.js.map +0 -1
  216. package/dist/chunk-VQ47KWD5.js.map +0 -1
  217. package/dist/chunk-WUJXJC5O.js +0 -14568
  218. package/dist/chunk-WUJXJC5O.js.map +0 -1
  219. package/dist/chunk-ZORVNOKX.js.map +0 -1
  220. package/src/canvas/usePanZoom.test.ts +0 -244
  221. package/src/canvas/usePanZoom.ts +0 -147
  222. package/src/state/Persistence.stories.tsx +0 -75
@@ -1,6 +1,10 @@
1
1
  // The Oswald and Inter faces come from @weasel-js/theme/faces.css, imported at
2
2
  // the top of styles.less.
3
3
 
4
+ // weasel-ui's native range skin, read for `:extend` below and emitted only
5
+ // through it.
6
+ @import (reference, less) '../../../ui/src/components/range.module.css';
7
+
4
8
  // Resets and global element defaults.
5
9
  .lk-root {
6
10
  font-family: var(--wzl-font-ui);
@@ -21,7 +25,10 @@
21
25
  // Deliberate overrides of a weasel-ui component go the other way — see the
22
26
  // `.lk-root` prefix convention below.
23
27
  :where(.lk-root) {
24
- :where(button) {
28
+ // Only a button with no class or a labkit one. Specificity cannot keep this
29
+ // off a weasel-ui button: whatever its module leaves unset — a height, a
30
+ // backdrop blur — the default fills in.
31
+ :where(button:not([class]), button[class^='lk-'], button[class*=' lk-']) {
25
32
  font: inherit;
26
33
  color: inherit;
27
34
  background: var(--wzl-surface-raised);
@@ -33,63 +40,32 @@
33
40
  backdrop-filter: blur(var(--wzl-glass-blur));
34
41
  -webkit-backdrop-filter: blur(var(--wzl-glass-blur));
35
42
 
36
- &:hover {
43
+ &:where(:hover) {
37
44
  border-color: var(--wzl-accent);
38
45
  color: var(--wzl-accent-fg);
39
46
  }
40
47
 
41
- &:focus-visible {
48
+ &:where(:focus-visible) {
42
49
  outline: 2px solid var(--wzl-focus-ring);
43
50
  outline-offset: 1px;
44
51
  }
45
52
 
46
- &:disabled {
53
+ &:where(:disabled) {
47
54
  color: var(--wzl-fg-subtle);
48
55
  cursor: not-allowed;
49
56
  }
50
57
  }
51
58
 
52
- // Mirrors packages/ui/src/components/range.module.css so a consumer's bare
53
- // range matches the kit's. A weasel-ui component's own module class outranks
54
- // this, which is what keeps InlineRange's gradient track intact.
55
- :where(input[type="range"]) {
56
- appearance: none;
57
- -webkit-appearance: none;
58
- height: 12px;
59
- background: transparent;
60
- cursor: pointer;
61
-
62
- &::-webkit-slider-runnable-track {
63
- height: var(--wzl-slider-track-h);
64
- border-radius: var(--wzl-radius-sm);
65
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
66
- }
67
-
68
- &::-moz-range-track {
69
- height: var(--wzl-slider-track-h);
70
- border-radius: var(--wzl-radius-sm);
71
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-mix), transparent);
72
- }
73
-
74
- &::-webkit-slider-thumb {
75
- appearance: none;
76
- -webkit-appearance: none;
77
- width: var(--wzl-slider-thumb-size);
78
- height: var(--wzl-slider-thumb-size);
79
- border: none;
80
- border-radius: 50%;
81
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
82
- margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
83
- }
84
-
85
- &::-moz-range-thumb {
86
- width: var(--wzl-slider-thumb-size);
87
- height: var(--wzl-slider-thumb-size);
88
- border: none;
89
- border-radius: 50%;
90
- background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-mix), transparent);
91
- }
59
+ // The browser sets these in its own `monospace`, which no font token reaches.
60
+ :where(code, kbd, samp, pre) {
61
+ font-family: var(--wzl-font-mono);
92
62
  }
63
+
64
+ // A consumer's bare range wears weasel-ui's range skin, extended from its one
65
+ // definition rather than copied. The skin keeps its pseudo-classes inside
66
+ // :where(), so nothing this emits outranks a single class and a weasel-ui
67
+ // component's own module class still wins.
68
+ :where(input[type="range"]):extend(.range all) {}
93
69
  }
94
70
 
95
71
  // The `.lk-root` prefix convention: a labkit class handed to a weasel-ui
@@ -151,9 +127,7 @@
151
127
  }
152
128
 
153
129
  // Headline / display text uses Oswald light at tighter line-height.
154
- .lk-lab h1,
155
- .lk-lab h2,
156
- .lk-lab h3 {
130
+ :where(.lk-lab) :where(h1, h2, h3) {
157
131
  font-family: var(--wzl-font-display);
158
132
  font-weight: var(--wzl-font-weight-light);
159
133
  letter-spacing: 0.01em;
@@ -18,7 +18,7 @@ describe('interstellarTheme', () => {
18
18
 
19
19
  // Values carried over verbatim from the retired Less.
20
20
  expect(dark['--wzl-surface']).toBe('#0a0a14');
21
- expect(dark['--wzl-accent']).toBe('#b08adb');
21
+ expect(dark['--wzl-accent']).toBe('#4f46e5');
22
22
  // Light mode is weasel's own: interstellar restyles the dark only.
23
23
  const weasel = resolveTheme(weaselTheme, { mode: 'light' });
24
24
  expect(light['--wzl-surface']).toBe(weasel['--wzl-surface']);
@@ -81,21 +81,28 @@
81
81
  "accent": {
82
82
  "by": "mode",
83
83
  "dark": {
84
- "value": "#b08adb",
84
+ "value": "#4f46e5",
85
85
  "type": "color"
86
86
  }
87
87
  },
88
88
  "accent-hover": {
89
89
  "by": "mode",
90
90
  "dark": {
91
- "value": "#c6a3ee",
91
+ "value": "#6d5dfc",
92
+ "type": "color"
93
+ }
94
+ },
95
+ "accent-strong": {
96
+ "by": "mode",
97
+ "dark": {
98
+ "value": "#7474fe",
92
99
  "type": "color"
93
100
  }
94
101
  },
95
102
  "focus-ring": {
96
103
  "by": "mode",
97
104
  "dark": {
98
- "value": "rgba(176, 138, 219, 0.45)",
105
+ "value": "rgba(116, 116, 254, 0.5)",
99
106
  "type": "color"
100
107
  }
101
108
  },
@@ -1,11 +1,15 @@
1
+ import { labAnnotationTools } from '../annotations/toolMap';
1
2
  import type { InstrumentList } from '../instrument/types';
2
3
 
3
4
  /**
4
5
  * The tool the lab's slot holds, once its default is applied: a lab with any
5
6
  * annotating instrument starts in `pointer`, so a first click reaches the
6
- * instrument rather than the marks over it.
7
+ * instrument rather than the marks over it, or in the first annotation tool
8
+ * its rail carries when `pointer` is not among them.
7
9
  */
8
10
  export function resolveLabTool(slot: string | null, instruments: InstrumentList): string | null {
9
11
  if (slot !== null) return slot;
10
- return instruments.some((i) => i.annotations != null) ? 'pointer' : null;
12
+ const tools = labAnnotationTools(instruments);
13
+ if (tools.some((t) => t.id === 'pointer')) return 'pointer';
14
+ return tools[0]?.id ?? null;
11
15
  }
@@ -69,6 +69,22 @@ describe('an instrument that declares annotations', () => {
69
69
  }
70
70
  });
71
71
 
72
+ it('gets only the tools it names', () => {
73
+ const narrow = defineInstrument<Record<string, never>, Record<string, never>>({
74
+ ...annotating,
75
+ name: 'Narrow',
76
+ annotations: {
77
+ targets: () => [{ id: 'pane', ref: { current: null }, content: { w: 200, h: 100 } }],
78
+ tools: ['pointer', 'select'],
79
+ },
80
+ });
81
+ render(<Lab instruments={[narrow]} defaultInstrument="Narrow" />);
82
+ expect(screen.getByRole('button', { name: 'Interact' })).toBeTruthy();
83
+ expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
84
+ expect(screen.queryByRole('button', { name: 'Freehand' })).toBeNull();
85
+ expect(screen.queryByRole('button', { name: 'Rectangle' })).toBeNull();
86
+ });
87
+
72
88
  it('puts the store in reach of its own render', () => {
73
89
  render(<Lab instruments={[annotating]} defaultInstrument="Annotating" />);
74
90
  expect(screen.getByTestId('pane').dataset.marks).toBe('0');
@@ -109,19 +109,6 @@
109
109
  }
110
110
  }
111
111
 
112
- .lk-toolbar__zoom-label {
113
- font-size: var(--wzl-font-size-sm);
114
- color: var(--wzl-fg-muted);
115
- padding: 0 var(--wzl-space-sm);
116
- }
117
-
118
- .lk-sidebar__placeholder {
119
- padding: var(--wzl-space-sm);
120
- color: var(--wzl-fg-muted);
121
- font-size: var(--wzl-font-size-sm);
122
- }
123
-
124
-
125
112
  .lk-trial__titlebar {
126
113
  display: flex;
127
114
  align-items: center;
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { beforeAll, describe, expect, it, vi } from 'vitest';
4
4
  import { defineInstrument } from '../instrument/defineInstrument';
@@ -126,3 +126,75 @@ describe('the loupe capability', () => {
126
126
  expect(lens(container)).toBeNull();
127
127
  });
128
128
  });
129
+
130
+ describe('the loupe and the trial camera share one dispatcher', () => {
131
+ const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
132
+
133
+ function mountDrawn() {
134
+ const zooms: number[] = [];
135
+ const drawn = defineInstrument<Record<string, never>, Record<string, never>>({
136
+ name: 'Zoomed',
137
+ defaultConfig: () => ({}),
138
+ initialState: () => ({}),
139
+ render: () => null,
140
+ canvas: {
141
+ layers: [
142
+ {
143
+ id: 'main',
144
+ draw: (_ctx, { zoom }) => {
145
+ zooms.push(zoom);
146
+ },
147
+ },
148
+ ],
149
+ },
150
+ loupe: true,
151
+ });
152
+ const r = render(<Lab instruments={[drawn]} defaultInstrument="Zoomed" />);
153
+ const stack = r.container.querySelector('.lk-canvas-stack');
154
+ if (!stack) throw new Error('no canvas stack');
155
+ return { ...r, stack, zooms };
156
+ }
157
+
158
+ const wheel = (el: Element) => {
159
+ const e = new WheelEvent('wheel', {
160
+ deltaY: -300,
161
+ clientX: 50,
162
+ clientY: 50,
163
+ bubbles: true,
164
+ cancelable: true,
165
+ });
166
+ el.dispatchEvent(e);
167
+ return e;
168
+ };
169
+
170
+ it('zooms the camera while the lens is down', async () => {
171
+ const h = mountDrawn();
172
+ await act(async () => {
173
+ await frame();
174
+ });
175
+ await act(async () => {
176
+ wheel(h.stack);
177
+ await frame();
178
+ });
179
+ expect(h.zooms.at(-1)).toBeGreaterThan(1);
180
+ });
181
+
182
+ it('gives the wheel to the lens while it is up, and leaves the camera alone', async () => {
183
+ const user = userEvent.setup();
184
+ const h = mountDrawn();
185
+ await user.click(screen.getByRole('button', { name: 'Loupe' }));
186
+ pointAt(h.stack, 60, 40);
187
+ expect(lens(h.container)).not.toBeNull();
188
+ await act(async () => {
189
+ await frame();
190
+ });
191
+ const before = h.zooms.at(-1);
192
+ let e!: WheelEvent;
193
+ await act(async () => {
194
+ e = wheel(h.stack);
195
+ await frame();
196
+ });
197
+ expect(e.defaultPrevented).toBe(true);
198
+ expect(h.zooms.at(-1)).toBe(before);
199
+ });
200
+ });
@@ -1,3 +1,4 @@
1
+ import { createPointerStore } from '@weasel-js/core';
1
2
  import type { Meta, StoryObj } from '@weasel-js/forge';
2
3
  import type { Instrument } from '../instrument/types';
3
4
  import { LabContext, type LabContextValue } from '../lab/LabContext';
@@ -63,6 +64,7 @@ function Harness() {
63
64
  setZoom: () => {},
64
65
  activeToolId: null,
65
66
  visibleLayers: [],
67
+ pointer: createPointerStore(),
66
68
  },
67
69
  emit: () => {},
68
70
  })}
@@ -1,3 +1,4 @@
1
+ import { createPointerStore, PointerContextProvider, WeaselProvider } from '@weasel-js/core';
1
2
  import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
3
  import { useStore } from 'zustand/react';
3
4
  import { AnnotationsContext } from '../annotations/AnnotationsContext';
@@ -5,8 +6,10 @@ import { AnnotationTargets } from '../annotations/AnnotationTargets';
5
6
  import { AnnotationPreloadContext } from '../annotations/preload';
6
7
  import { annotationsFromJSON } from '../annotations/store';
7
8
  import type { AnnotationStorage, AnnotationTargetInfo } from '../annotations/types';
9
+ import type { CameraView } from '../canvas/CameraInput';
8
10
  import { CameraWheelContext, type CameraWheelSlot } from '../canvas/CameraWheelContext';
9
11
  import { CanvasStack } from '../canvas/CanvasStack';
12
+ import { CameraPublishContext, CameraRegistryContext } from '../canvas/cameraRegistry';
10
13
  import { fitStage, Stage } from '../canvas/Stage';
11
14
  import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
12
15
  import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
@@ -134,6 +137,13 @@ function TrialRuntime({
134
137
  suppress,
135
138
  }: TrialRuntimeProps) {
136
139
  const wheelSlot = useRef<CameraWheelSlot['current']>(null);
140
+ const cameras = useContext(CameraRegistryContext);
141
+ const trialId = record.id;
142
+ const publishCamera = useMemo(
143
+ () => (cameras ? (camera: CameraView) => cameras.register(trialId, camera) : null),
144
+ [cameras, trialId],
145
+ );
146
+ const [pointer] = useState(createPointerStore);
137
147
  const canvasContainerRef = useRef<HTMLDivElement | null>(null);
138
148
  const loupeHostRef = useRef<HTMLDivElement | null>(null);
139
149
  const updateTrialState = useStore(store, (s) => s.updateTrialState);
@@ -328,6 +338,7 @@ function TrialRuntime({
328
338
  },
329
339
  activeToolId: resolvedToolId,
330
340
  visibleLayers,
341
+ pointer,
331
342
  },
332
343
  emit: (event) => {
333
344
  snapshotIfNeeded(event);
@@ -589,6 +600,17 @@ function TrialRuntime({
589
600
  />
590
601
  ) : null;
591
602
 
603
+ // The trial's root registries, isolated from the trial beside it. Each
604
+ // camera inside mounts an input scope under them; chrome here reaches the
605
+ // one last used. The pointer store is the trial's, shared by all of them.
606
+ const scopedBody = (
607
+ <WeaselProvider isolate>
608
+ <PointerContextProvider store={pointer}>
609
+ <CameraPublishContext.Provider value={publishCamera}>{body}</CameraPublishContext.Provider>
610
+ </PointerContextProvider>
611
+ </WeaselProvider>
612
+ );
613
+
592
614
  return (
593
615
  <TrialIdProvider trialId={record.id}>
594
616
  <AnnotationsContext.Provider value={annotationsCap ? annotations : null}>
@@ -607,7 +629,7 @@ function TrialRuntime({
607
629
  activeToolId={resolvedToolId}
608
630
  setActiveTool={setActiveTool}
609
631
  >
610
- {body}
632
+ {scopedBody}
611
633
  {annotationOverlays}
612
634
  </TrialChrome>
613
635
  </CameraWheelContext.Provider>
@@ -1,5 +1,13 @@
1
- import { type KeyboardEvent, type ReactNode, useContext, useMemo } from 'react';
1
+ import {
2
+ type KeyboardEvent,
3
+ type ReactNode,
4
+ useContext,
5
+ useMemo,
6
+ useSyncExternalStore,
7
+ } from 'react';
2
8
  import { useStore } from 'zustand/react';
9
+ import { CameraRegistryContext } from '../canvas/cameraRegistry';
10
+ import { zoomCameraTo } from '../canvas/cameraZoom';
3
11
  import { builtinContributions } from '../chrome/builtins';
4
12
  import { mergeContributions, suppressContributions } from '../chrome/merge';
5
13
  import { PaletteRegion } from '../chrome/regions/PaletteRegion';
@@ -68,6 +76,7 @@ const NO_OP = (): void => {};
68
76
  /** One frozen empty map, so a trial that has folded nothing keeps a stable
69
77
  * context identity across renders. */
70
78
  const NO_SECTIONS: Readonly<Record<string, boolean>> = Object.freeze({});
79
+ const noSubscribe = (): (() => void) => NO_OP;
71
80
 
72
81
  /** The frame around a running instrument. Builds one chrome context, assembles
73
82
  * the contributions the instrument, the runtime and the lab declare, and hands
@@ -123,10 +132,18 @@ export function TrialChrome({
123
132
  const title = record.title ?? instrument.title ?? instrument.name;
124
133
  const collapsedSections = record.collapsedSections ?? NO_SECTIONS;
125
134
 
135
+ // The trial's own camera, where it has one: zoom goes through it, about the
136
+ // middle of the view and inside the instrument's range, as the lab's does.
137
+ const cameras = useContext(CameraRegistryContext);
138
+ const camera = useSyncExternalStore(
139
+ cameras?.subscribe ?? noSubscribe,
140
+ () => cameras?.get(trialId) ?? null,
141
+ );
142
+
126
143
  const ctx = useMemo<TrialChromeContext>(() => {
127
144
  const setZoom = (z: number): void => {
128
- if (!view2d) return;
129
- updateTrialView(trialId, withZoom(view2d, z));
145
+ if (camera) zoomCameraTo(camera, z);
146
+ else if (view2d) updateTrialView(trialId, withZoom(view2d, z));
130
147
  };
131
148
  return {
132
149
  trialId,
@@ -203,6 +220,7 @@ export function TrialChrome({
203
220
  setTrialTitle,
204
221
  setTrialSectionCollapsed,
205
222
  collapsedSections,
223
+ camera,
206
224
  ]);
207
225
 
208
226
  const contributions = useMemo(
@@ -132,7 +132,7 @@ describe('addTrial', () => {
132
132
  const out = addTrial([], instruments, 'Schemed', { config: { gap: auto } });
133
133
  expect(out[0]?.auto).toContain('gap');
134
134
  expect(out[0]?.autoSeed).toEqual(['gap']);
135
- expect((out[0]?.config as { gap: number }).gap).toBe(12);
135
+ expect((out[0]?.config as { gap: number } | undefined)?.gap).toBe(12);
136
136
  expect(out[0]?.configSeed).not.toHaveProperty('gap');
137
137
  });
138
138
 
@@ -141,13 +141,13 @@ describe('addTrial', () => {
141
141
  const out = addTrial([], instruments, 'Counter', {
142
142
  config: { at: when } as unknown as Partial<CounterConfig>,
143
143
  });
144
- expect((out[0]?.config as { at: unknown }).at).toBe(when);
144
+ expect((out[0]?.config as { at: unknown } | undefined)?.at).toBe(when);
145
145
  });
146
146
 
147
147
  it('honors auto at a nested seed path', () => {
148
148
  const out = addTrial([], instruments, 'Schemed', { config: { grid: { size: auto } } });
149
149
  expect(out[0]?.auto).toContain('grid.size');
150
- expect((out[0]?.config as { grid: { size: number } }).grid.size).toBe(4);
150
+ expect((out[0]?.config as { grid: { size: number } } | undefined)?.grid.size).toBe(4);
151
151
  });
152
152
 
153
153
  it('leaves a trial with no auto field when nothing is unpinned', () => {
@@ -222,7 +222,7 @@ describe('resetTrial', () => {
222
222
  const arr = addTrial([], instruments, 'Counter');
223
223
  head(arr).config = { step: 99 } as CounterConfig;
224
224
  resetTrial(arr, head(arr).id, instruments);
225
- expect((arr[0]?.config as CounterConfig).step).toBe(99);
225
+ expect((arr[0]?.config as CounterConfig | undefined)?.step).toBe(99);
226
226
  });
227
227
 
228
228
  it('restores the seed the trial was opened with, not the bare defaults', () => {
@@ -1,39 +0,0 @@
1
- import { Stance } from '@weasel-js/theme';
2
- import { ReactNode } from 'react';
3
- import { ToolPrefLeaf } from '@weasel-js/core';
4
-
5
- /**
6
- * What a surface holds, and which of its peers it is. Every surface that takes
7
- * these takes both, and the theme decides how each looks.
8
- */
9
- interface StanceProps {
10
- /** The class of content the surface holds. Omitted, it draws as it always has. */
11
- stance?: Stance;
12
- /**
13
- * Which of its peers this is: an index into the theme's tone list, wrapping,
14
- * or a color given directly. Independent of `stance`, and allowed without one.
15
- */
16
- tone?: number | string;
17
- }
18
-
19
- /** What a {@link PrefRenderer} is given for the leaf it is rendering. */
20
- interface PrefRenderContext {
21
- /** Dotted path of the leaf within the schema root. */
22
- path: string;
23
- /** The schema node. App renderers narrow this to their own kind shape. */
24
- pref: ToolPrefLeaf;
25
- /** Current value — `values` at `path`, falling back to `pref.default`. */
26
- value: unknown;
27
- setValue: (value: unknown) => void;
28
- /** Whether this leaf is currently auto — not pinned, computed by the owner. */
29
- auto: boolean;
30
- /** Toggle this leaf's auto state. */
31
- setAuto: (next: boolean) => void;
32
- }
33
- /**
34
- * Renders the control cell for one preference leaf. Returning `null`
35
- * collapses the row.
36
- */
37
- type PrefRenderer = (ctx: PrefRenderContext) => ReactNode;
38
-
39
- export type { PrefRenderer as P, StanceProps as S, PrefRenderContext as a };
@@ -1,23 +0,0 @@
1
- import { ReactNode } from 'react';
2
- import { C as ControlRenderer } from './types-B7y8hX1D.js';
3
-
4
- /** Options for {@link inDialog}. */
5
- interface InDialogOptions {
6
- /** What the row's button shows of the value. Defaults to a list joined with
7
- * commas, or the value itself. */
8
- summary?: (value: unknown) => ReactNode;
9
- /** The dialog's heading. Defaults to the row's label. */
10
- title?: ReactNode;
11
- }
12
- /** The one-line summary a dialog row shows when it is given none. */
13
- declare function summarizeValue(value: unknown): string;
14
- /**
15
- * Moves a control into a modal: the row becomes a button showing a summary of
16
- * the value, and the button opens a dialog whose body is `body`, handed the
17
- * same context a row renderer gets. For a leaf whose editor will never fit a
18
- * sidebar row.
19
- */
20
- declare function inDialog(body: ControlRenderer, opts?: InDialogOptions): ControlRenderer;
21
-
22
- export { inDialog as i, summarizeValue as s };
23
- export type { InDialogOptions as I };