@weasel-js/labkit 1.6.1 → 1.7.1

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 (256) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-D6t7o0pF.d.ts} +59 -18
  4. package/dist/_dts/{index-CKyX0ME_.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-B7yBTTF9.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-DTuViBvc.d.ts → types-BUBSIZuV.d.ts} +18 -8
  7. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-Bh8bzFgE.d.ts} +1 -1
  8. package/dist/canvas/index.d.ts +8 -6
  9. package/dist/canvas/index.js +6 -6
  10. package/dist/chrome/index.d.ts +6 -4
  11. package/dist/chrome/index.js +11 -10
  12. package/dist/chunk-2ES4W47N.js +8 -0
  13. package/dist/chunk-2ES4W47N.js.map +1 -0
  14. package/dist/{chunk-WPLAANH4.js → chunk-2N7UBSS2.js} +12 -12
  15. package/dist/chunk-2N7UBSS2.js.map +1 -0
  16. package/dist/chunk-4KSC7NXL.js +15491 -0
  17. package/dist/chunk-4KSC7NXL.js.map +1 -0
  18. package/dist/{chunk-SLAQPUI4.js → chunk-CEGFXTBD.js} +10 -63
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/chunk-CZZPXUOU.js +3 -0
  21. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  22. package/dist/{chunk-QGMOA2YE.js → chunk-E23Q6A3Y.js} +296 -104
  23. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  24. package/dist/{chunk-GHGO52SO.js → chunk-E6NARGU7.js} +5 -5
  25. package/dist/{chunk-GHGO52SO.js.map → chunk-E6NARGU7.js.map} +1 -1
  26. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  27. package/dist/chunk-EESJUSYR.js.map +1 -0
  28. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  29. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  30. package/dist/chunk-F4ACIK7Z.js +210 -0
  31. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  32. package/dist/{chunk-6FLXL3I7.js → chunk-FB4MIZOO.js} +77 -45
  33. package/dist/chunk-FB4MIZOO.js.map +1 -0
  34. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  35. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  36. package/dist/{chunk-4TT3PNPR.js → chunk-GAHELLJT.js} +95 -95
  37. package/dist/chunk-GAHELLJT.js.map +1 -0
  38. package/dist/{chunk-HKV7LRHY.js → chunk-IBVRMHW5.js} +5 -5
  39. package/dist/{chunk-HKV7LRHY.js.map → chunk-IBVRMHW5.js.map} +1 -1
  40. package/dist/{chunk-ASOK76ZH.js → chunk-JFOJESJF.js} +72 -29
  41. package/dist/chunk-JFOJESJF.js.map +1 -0
  42. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  43. package/dist/chunk-PMLZP7RT.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-READTDQX.js} +5 -9
  45. package/dist/chunk-READTDQX.js.map +1 -0
  46. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  47. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-T6DLGEJB.js} +18 -18
  49. package/dist/chunk-T6DLGEJB.js.map +1 -0
  50. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  51. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  52. package/dist/{chunk-DWMSB4CA.js → chunk-VYVGVBGY.js} +95 -32
  53. package/dist/chunk-VYVGVBGY.js.map +1 -0
  54. package/dist/{chunk-IRTGUQ5O.js → chunk-WLMDTI2Q.js} +16 -6
  55. package/dist/chunk-WLMDTI2Q.js.map +1 -0
  56. package/dist/{chunk-NEXR37YJ.js → chunk-XE5HJOPL.js} +6 -8
  57. package/dist/chunk-XE5HJOPL.js.map +1 -0
  58. package/dist/chunk-XVUKDSYV.js +38 -0
  59. package/dist/chunk-XVUKDSYV.js.map +1 -0
  60. package/dist/{chunk-O6FFFOHS.js → chunk-YCFKW5RX.js} +16 -8
  61. package/dist/chunk-YCFKW5RX.js.map +1 -0
  62. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  63. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  64. package/dist/config/index.d.ts +28 -13
  65. package/dist/config/index.js +4 -4
  66. package/dist/controls/index.d.ts +4 -7
  67. package/dist/controls/index.js +6 -5
  68. package/dist/dragdrop/index.d.ts +23 -3
  69. package/dist/dragdrop/index.js +4 -3
  70. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  71. package/dist/index.d.ts +72 -487
  72. package/dist/index.js +603 -441
  73. package/dist/index.js.map +1 -1
  74. package/dist/job/index.d.ts +8 -0
  75. package/dist/job/index.js +1 -1
  76. package/dist/layers/index.d.ts +6 -5
  77. package/dist/layers/index.js +3 -2
  78. package/dist/loupe/index.d.ts +100 -30
  79. package/dist/loupe/index.js +585 -6
  80. package/dist/loupe/index.js.map +1 -1
  81. package/dist/overview/index.d.ts +4 -2
  82. package/dist/overview/index.js +20 -18
  83. package/dist/overview/index.js.map +1 -1
  84. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  85. package/dist/passthrough/weasel-ui.d.ts +2 -2701
  86. package/dist/passthrough/weasel-ui.js +4 -3
  87. package/dist/primitives/index.d.ts +37 -2
  88. package/dist/primitives/index.js +9 -8
  89. package/dist/state/index.d.ts +7 -5
  90. package/dist/state/index.js +6 -6
  91. package/dist/styles.css +150 -126
  92. package/dist/surface/index.d.ts +10 -2
  93. package/dist/surface/index.js +1 -1
  94. package/dist/undo/index.d.ts +5 -3
  95. package/package.json +14 -13
  96. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  97. package/src/annotations/AnnotationOverlay.tsx +52 -33
  98. package/src/annotations/Annotations.less +0 -10
  99. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  100. package/src/annotations/MarkList.tsx +4 -8
  101. package/src/annotations/capture.browser.test.ts +41 -0
  102. package/src/annotations/capture.ts +14 -7
  103. package/src/annotations/drawOne.ts +2 -2
  104. package/src/annotations/frac.test.ts +44 -1
  105. package/src/annotations/frac.ts +46 -18
  106. package/src/annotations/index.ts +1 -0
  107. package/src/annotations/paint.test.ts +76 -46
  108. package/src/annotations/paint.ts +65 -43
  109. package/src/annotations/store.test.ts +212 -2
  110. package/src/annotations/store.ts +130 -27
  111. package/src/annotations/svgNodes.test.ts +28 -19
  112. package/src/annotations/svgNodes.ts +6 -6
  113. package/src/annotations/types.ts +29 -10
  114. package/src/canvas/AGENTS.md +1 -0
  115. package/src/canvas/CameraInput.test.tsx +30 -0
  116. package/src/canvas/CameraInput.tsx +60 -48
  117. package/src/canvas/CanvasStack.tsx +11 -6
  118. package/src/canvas/CanvasStackContext.ts +4 -1
  119. package/src/canvas/LinkedCursor.less +1 -1
  120. package/src/canvas/Stage.test.tsx +33 -4
  121. package/src/canvas/Stage.tsx +9 -4
  122. package/src/canvas/cameraRegistry.test.ts +55 -0
  123. package/src/canvas/cameraRegistry.ts +87 -0
  124. package/src/canvas/cameraZoom.test.ts +46 -0
  125. package/src/canvas/cameraZoom.ts +24 -0
  126. package/src/canvas/index.ts +14 -2
  127. package/src/canvas/sharedScope.test.tsx +94 -0
  128. package/src/canvas/worldSpec.ts +3 -1
  129. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  130. package/src/chrome/builtins.test.ts +7 -6
  131. package/src/chrome/builtins.tsx +5 -1
  132. package/src/chrome/index.ts +1 -1
  133. package/src/chrome/merge.test.ts +5 -0
  134. package/src/chrome/merge.ts +4 -2
  135. package/src/chrome/regions/PaletteRegion.less +0 -7
  136. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  137. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  138. package/src/chrome/regions/SidebarRegion.less +12 -2
  139. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  140. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  141. package/src/chrome/regions/ViewportRegion.less +6 -0
  142. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  143. package/src/chrome/types.ts +4 -2
  144. package/src/config/auto.ts +1 -0
  145. package/src/config/builder.test.ts +2 -1
  146. package/src/config/builder.ts +18 -5
  147. package/src/config/fromConfigField.test.ts +14 -0
  148. package/src/config/fromConfigField.ts +18 -4
  149. package/src/config/path.ts +2 -5
  150. package/src/config/resolve.ts +4 -0
  151. package/src/config/types.ts +14 -9
  152. package/src/controls/ControlPanel.less +1 -1
  153. package/src/controls/ControlPanel.stories.tsx +3 -2
  154. package/src/controls/ControlPanel.test.tsx +98 -11
  155. package/src/controls/ControlPanel.tsx +61 -2
  156. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  157. package/src/dragdrop/DragGhost.tsx +3 -0
  158. package/src/dragdrop/Palette.tsx +2 -0
  159. package/src/index.test.ts +5 -0
  160. package/src/index.ts +16 -2
  161. package/src/instrument/types.ts +0 -6
  162. package/src/job/useJob.ts +14 -9
  163. package/src/lab/Lab.tsx +88 -73
  164. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  165. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  166. package/src/lab/LabFullChrome.stories.tsx +1 -0
  167. package/src/lab/LabHeader.test.tsx +24 -14
  168. package/src/lab/LabHeader.tsx +11 -12
  169. package/src/lab/LabShell.less +9 -17
  170. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  171. package/src/lab/LabSwitcher.less +8 -6
  172. package/src/lab/LabZoom.test.tsx +223 -0
  173. package/src/lab/LabZoom.tsx +170 -0
  174. package/src/lab/Workspace.tsx +48 -52
  175. package/src/lab/index.ts +1 -0
  176. package/src/loupe/AGENTS.md +28 -15
  177. package/src/loupe/CanvasLoupe.tsx +2 -3
  178. package/src/loupe/DomLoupe.tsx +1 -7
  179. package/src/loupe/Loupe.less +16 -0
  180. package/src/loupe/LoupeGestures.tsx +1 -1
  181. package/src/loupe/TrialLoupe.tsx +120 -57
  182. package/src/loupe/index.ts +1 -6
  183. package/src/loupe/types.test.ts +3 -3
  184. package/src/loupe/types.ts +24 -27
  185. package/src/loupe/useLoupe.test.tsx +29 -4
  186. package/src/loupe/useLoupe.ts +20 -17
  187. package/src/overview/OverviewMarks.tsx +12 -4
  188. package/src/overview/TrialOverview.tsx +10 -8
  189. package/src/passthrough/weasel-ui.test.ts +2 -0
  190. package/src/passthrough/weasel-ui.ts +17 -5
  191. package/src/primitives/FloatingPanel.test.tsx +11 -0
  192. package/src/primitives/FloatingPanel.tsx +8 -7
  193. package/src/primitives/JobProgress.less +2 -1
  194. package/src/primitives/Legend.stories.tsx +1 -0
  195. package/src/primitives/Readout.browser.test.tsx +137 -0
  196. package/src/primitives/Readout.less +12 -0
  197. package/src/primitives/Readout.stories.tsx +58 -0
  198. package/src/primitives/Readout.test.tsx +93 -0
  199. package/src/primitives/Readout.tsx +56 -0
  200. package/src/primitives/Split.tsx +69 -41
  201. package/src/primitives/Toolbar.tsx +3 -3
  202. package/src/primitives/ZoomControl.less +1 -2
  203. package/src/primitives/index.ts +2 -0
  204. package/src/specimen/Specimen.stories.tsx +1 -0
  205. package/src/specimen/Specimen.tsx +2 -3
  206. package/src/state/Persistence.browser.test.tsx +47 -0
  207. package/src/state/store.test.ts +5 -1
  208. package/src/state/undock.ts +3 -0
  209. package/src/state/useOpenOnce.ts +20 -16
  210. package/src/state/usePersistedState.test.tsx +1 -1
  211. package/src/state/view.ts +1 -0
  212. package/src/styles.less +1 -0
  213. package/src/surface/index.ts +1 -1
  214. package/src/surface/useTiledSurface.test.tsx +14 -0
  215. package/src/surface/useTiledSurface.ts +11 -3
  216. package/src/theme/Interstellar.stories.tsx +3 -1
  217. package/src/theme/base.less +21 -47
  218. package/src/theme/interstellar.test.ts +1 -1
  219. package/src/theme/interstellar.theme.json +10 -3
  220. package/src/trial/Trial.annotations.test.tsx +73 -0
  221. package/src/trial/Trial.less +0 -29
  222. package/src/trial/Trial.loupe.test.tsx +28 -11
  223. package/src/trial/Trial.tsx +85 -67
  224. package/src/trial/TrialChrome.tsx +27 -6
  225. package/src/trial/index.ts +2 -0
  226. package/src/trial/loupeSwitch.ts +15 -0
  227. package/dist/_dts/LayerList.d-BoELAMsM.d.ts +0 -83
  228. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  229. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  230. package/dist/_dts/Select.d-Cr8-zoup.d.ts +0 -569
  231. package/dist/chunk-4TT3PNPR.js.map +0 -1
  232. package/dist/chunk-6FLXL3I7.js.map +0 -1
  233. package/dist/chunk-ASOK76ZH.js.map +0 -1
  234. package/dist/chunk-CGHF6RZV.js.map +0 -1
  235. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  236. package/dist/chunk-DWMSB4CA.js.map +0 -1
  237. package/dist/chunk-HXXTULDN.js.map +0 -1
  238. package/dist/chunk-IJJUK4FU.js.map +0 -1
  239. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  240. package/dist/chunk-KHK5FGER.js +0 -145
  241. package/dist/chunk-KHK5FGER.js.map +0 -1
  242. package/dist/chunk-NEXR37YJ.js.map +0 -1
  243. package/dist/chunk-O6FFFOHS.js.map +0 -1
  244. package/dist/chunk-QGMOA2YE.js.map +0 -1
  245. package/dist/chunk-RWNDDKOH.js.map +0 -1
  246. package/dist/chunk-S4NCI32V.js +0 -549
  247. package/dist/chunk-S4NCI32V.js.map +0 -1
  248. package/dist/chunk-SBXNQW4G.js +0 -3
  249. package/dist/chunk-SLAQPUI4.js.map +0 -1
  250. package/dist/chunk-TJ77A4A3.js.map +0 -1
  251. package/dist/chunk-TYN6MLXX.js +0 -13864
  252. package/dist/chunk-TYN6MLXX.js.map +0 -1
  253. package/dist/chunk-WPLAANH4.js.map +0 -1
  254. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  255. package/src/primitives/useRovingTabIndex.ts +0 -88
  256. package/src/state/Persistence.stories.tsx +0 -75
@@ -0,0 +1,87 @@
1
+ import { useActionsRegistry } from '@weasel-js/core';
2
+ import { createContext, useContext, useEffect } from 'react';
3
+ import type { CameraView } from './CameraInput';
4
+
5
+ /**
6
+ * Cameras by key, for chrome that acts on a camera it does not contain — the
7
+ * lab's zoom controls, which drive whichever trial has the focus.
8
+ */
9
+ export interface CameraRegistry {
10
+ /** Publish `camera` under `key` and return its release. Registrants for one
11
+ * key stack, newest live. */
12
+ register(key: string, camera: CameraView): () => void;
13
+ get(key: string): CameraView | null;
14
+ /** Fires after any register or release. Shaped for `useSyncExternalStore`. */
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+
18
+ /** An empty in-memory `CameraRegistry`. */
19
+ export function createCameraRegistry(): CameraRegistry {
20
+ const stacks = new Map<string, CameraView[]>();
21
+ const listeners = new Set<() => void>();
22
+ const notify = (): void => {
23
+ for (const l of listeners) l();
24
+ };
25
+ return {
26
+ register(key, camera) {
27
+ const stack = stacks.get(key) ?? [];
28
+ stack.push(camera);
29
+ stacks.set(key, stack);
30
+ notify();
31
+ let released = false;
32
+ return () => {
33
+ if (released) return;
34
+ released = true;
35
+ const cur = stacks.get(key);
36
+ const i = cur?.lastIndexOf(camera) ?? -1;
37
+ if (!cur || i === -1) return;
38
+ cur.splice(i, 1);
39
+ if (cur.length === 0) stacks.delete(key);
40
+ notify();
41
+ };
42
+ },
43
+ get: (key) => stacks.get(key)?.at(-1) ?? null,
44
+ subscribe(listener) {
45
+ listeners.add(listener);
46
+ return () => {
47
+ listeners.delete(listener);
48
+ };
49
+ },
50
+ };
51
+ }
52
+
53
+ /** The registry a lab keeps its trials' cameras in. */
54
+ export const CameraRegistryContext = createContext<CameraRegistry | null>(null);
55
+
56
+ /** Where a camera publishes itself. The host binds the key — a trial provides
57
+ * one that registers under its own id — so the camera needs no name. */
58
+ export const CameraPublishContext = createContext<((camera: CameraView) => () => void) | null>(
59
+ null,
60
+ );
61
+
62
+ /** Publish `camera` to the host in scope for as long as the caller is mounted,
63
+ * and again each time its input scope becomes the active one — a registry
64
+ * key stacks newest-live, so the camera last used is the one chrome reaches.
65
+ * Call it inside the camera's scope. */
66
+ export function usePublishCamera(camera: CameraView): void {
67
+ const publish = useContext(CameraPublishContext);
68
+ const actions = useActionsRegistry();
69
+ useEffect(() => {
70
+ if (!publish) return;
71
+ let release = publish(camera);
72
+ if (!actions) return release;
73
+ let was = actions.isActive();
74
+ const off = actions.subscribe(() => {
75
+ const now = actions.isActive();
76
+ if (now && !was) {
77
+ release();
78
+ release = publish(camera);
79
+ }
80
+ was = now;
81
+ });
82
+ return () => {
83
+ off();
84
+ release();
85
+ };
86
+ }, [publish, camera, actions]);
87
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CameraView } from './CameraInput';
3
+ import { zoomCameraTo } from './cameraZoom';
4
+
5
+ function fakeCamera(
6
+ host: { width: number; height: number } | null,
7
+ range = { min: 0.1, max: 32 },
8
+ ): CameraView & {
9
+ view: { x: number; y: number; scale: { x: number; y: number } };
10
+ } {
11
+ const cam = {
12
+ view: { x: 0, y: 0, scale: { x: 1, y: 1 } },
13
+ get: () => cam.view,
14
+ set: (v: { x: number; y: number; scale: { x: number; y: number } }) => {
15
+ const s = Math.min(range.max, Math.max(range.min, v.scale.x));
16
+ cam.view = { ...v, scale: { x: s, y: s } };
17
+ },
18
+ hostSize: () => host,
19
+ zoomRange: () => range,
20
+ };
21
+ return cam;
22
+ }
23
+
24
+ describe('zoomCameraTo', () => {
25
+ it('keeps the middle of the view where it was', () => {
26
+ const cam = fakeCamera({ width: 200, height: 100 });
27
+ zoomCameraTo(cam, 2);
28
+ // The world point under the screen middle (100, 50) is still under it.
29
+ expect(cam.view.x + 100 / cam.view.scale.x).toBeCloseTo(100);
30
+ expect(cam.view.y + 50 / cam.view.scale.y).toBeCloseTo(50);
31
+ expect(cam.view.scale.x).toBe(2);
32
+ });
33
+
34
+ it('anchors at the origin when the camera cannot measure its host', () => {
35
+ const cam = fakeCamera(null);
36
+ zoomCameraTo(cam, 2);
37
+ expect(cam.view).toEqual({ x: 0, y: 0, scale: { x: 2, y: 2 } });
38
+ });
39
+
40
+ it('stops at the edge of the camera range', () => {
41
+ const cam = fakeCamera({ width: 200, height: 100 }, { min: 0.5, max: 1.2 });
42
+ zoomCameraTo(cam, 5);
43
+ expect(cam.view.scale.x).toBe(1.2);
44
+ expect(cam.view.x + 100 / cam.view.scale.x).toBeCloseTo(100);
45
+ });
46
+ });
@@ -0,0 +1,24 @@
1
+ import type { View } from '@weasel-js/core';
2
+ import type { CameraView } from './CameraInput';
3
+
4
+ /** `view` at `scale`, about the screen point `at`. */
5
+ export function atScale(view: View, scale: number, at: { x: number; y: number }): View {
6
+ return {
7
+ x: view.x + at.x / view.scale.x - at.x / scale,
8
+ y: view.y + at.y / view.scale.y - at.y / scale,
9
+ scale: { x: scale, y: scale },
10
+ };
11
+ }
12
+
13
+ /** The middle of the camera's host, in its own screen space; the origin when it cannot measure. */
14
+ export function viewMiddle(camera: CameraView): { x: number; y: number } {
15
+ const size = camera.hostSize?.();
16
+ return size ? { x: size.width / 2, y: size.height / 2 } : { x: 0, y: 0 };
17
+ }
18
+
19
+ /** Zoom `camera` to `zoom` about the middle of its view, within its range. */
20
+ export function zoomCameraTo(camera: CameraView, zoom: number): void {
21
+ const { min, max } = camera.zoomRange();
22
+ const scale = Math.min(max, Math.max(min, zoom));
23
+ camera.set(atScale(camera.get(), scale, viewMiddle(camera)));
24
+ }
@@ -1,9 +1,14 @@
1
- export type { CameraContextValue, CameraInputProps, CameraViewOptions } from './CameraInput';
1
+ export type {
2
+ CameraContextValue,
3
+ CameraInputProps,
4
+ CameraView,
5
+ CameraViewOptions,
6
+ ZoomRange,
7
+ } from './CameraInput';
2
8
  export {
3
9
  CameraContext,
4
10
  CameraInput,
5
11
  CameraScope,
6
- CameraScopeContext,
7
12
  STAGE_VIEW_ID,
8
13
  useCameraView,
9
14
  } from './CameraInput';
@@ -15,6 +20,13 @@ export type { CanvasStackContextValue, CanvasStackSurface } from './CanvasStackC
15
20
  export { CanvasStackContext } from './CanvasStackContext';
16
21
  export type { ZoomAtOptions } from './camera';
17
22
  export { centerOn, zoomAt } from './camera';
23
+ export type { CameraRegistry } from './cameraRegistry';
24
+ export {
25
+ CameraPublishContext,
26
+ CameraRegistryContext,
27
+ createCameraRegistry,
28
+ usePublishCamera,
29
+ } from './cameraRegistry';
18
30
  export { clampZoomAbout, frameLocalToWorld, fromCameraView, toCameraView } from './cameraView';
19
31
  export { screenToWorld, worldToScreen } from './canvasCoords';
20
32
  export { LinkedCursor } from './LinkedCursor';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Two cameras under one host scope — what a trial gives every canvas inside it,
3
+ * and what a forge index page puts several of in. Each must answer only for
4
+ * itself: the newest must not take the others' gestures.
5
+ */
6
+
7
+ import { act, render } from '@testing-library/react';
8
+ import { WeaselProvider } from '@weasel-js/core';
9
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
10
+ import type { ViewTransform } from '../instrument/types';
11
+ import { CanvasStack } from './CanvasStack';
12
+
13
+ beforeAll(() => {
14
+ HTMLCanvasElement.prototype.getContext = vi.fn(
15
+ () => null,
16
+ ) as unknown as HTMLCanvasElement['getContext'];
17
+ const proto = HTMLElement.prototype as unknown as Record<string, unknown>;
18
+ proto.setPointerCapture = vi.fn();
19
+ proto.releasePointerCapture = vi.fn();
20
+ proto.hasPointerCapture = vi.fn(() => true);
21
+ });
22
+
23
+ function pointer(el: Element, type: string, x: number, y: number, buttons = 1) {
24
+ el.dispatchEvent(
25
+ new PointerEvent(type, {
26
+ bubbles: true,
27
+ cancelable: true,
28
+ pointerId: 1,
29
+ clientX: x,
30
+ clientY: y,
31
+ button: 0,
32
+ buttons,
33
+ }),
34
+ );
35
+ }
36
+
37
+ const VIEW: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
38
+
39
+ function mountTwo() {
40
+ const views = [vi.fn(), vi.fn()];
41
+ const taps = [vi.fn(), vi.fn()];
42
+ const { container } = render(
43
+ <WeaselProvider isolate>
44
+ {(['a', 'b'] as const).map((id, i) => (
45
+ <CanvasStack
46
+ key={id}
47
+ layers={[]}
48
+ view={VIEW}
49
+ onViewChange={views[i]}
50
+ onHitTest={taps[i]}
51
+ width={100}
52
+ height={100}
53
+ />
54
+ ))}
55
+ </WeaselProvider>,
56
+ );
57
+ const hosts = [...container.querySelectorAll<HTMLElement>('.lk-canvas-stack')];
58
+ return { hosts, views, taps };
59
+ }
60
+
61
+ describe('cameras sharing a host scope', () => {
62
+ it('pans the camera the drag began on, not the newest', () => {
63
+ const { hosts, views } = mountTwo();
64
+ act(() => {
65
+ pointer(hosts[0]!, 'pointerdown', 50, 50);
66
+ pointer(hosts[0]!, 'pointermove', 60, 55);
67
+ pointer(hosts[0]!, 'pointermove', 80, 70);
68
+ pointer(hosts[0]!, 'pointerup', 80, 70, 0);
69
+ });
70
+ expect(views[0]).toHaveBeenCalled();
71
+ expect(views[1]).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('reports a tap to the canvas it landed on', () => {
75
+ const { hosts, taps } = mountTwo();
76
+ act(() => {
77
+ pointer(hosts[0]!, 'pointerdown', 50, 50);
78
+ pointer(hosts[0]!, 'pointerup', 50, 50, 0);
79
+ });
80
+ expect(taps[0]).toHaveBeenCalledTimes(1);
81
+ expect(taps[1]).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('still drives the newest camera from its own canvas', () => {
85
+ const { hosts, views } = mountTwo();
86
+ act(() => {
87
+ pointer(hosts[1]!, 'pointerdown', 50, 50);
88
+ pointer(hosts[1]!, 'pointermove', 80, 70);
89
+ pointer(hosts[1]!, 'pointerup', 80, 70, 0);
90
+ });
91
+ expect(views[1]).toHaveBeenCalled();
92
+ expect(views[0]).not.toHaveBeenCalled();
93
+ });
94
+ });
@@ -5,7 +5,7 @@ import type { Point, ViewTransform } from '../instrument/types';
5
5
  * world (0,0) at the element's top-left, y growing downward. */
6
6
  export interface WorldSpec {
7
7
  /** Where world (0,0) sits at `pan` zero, as a fraction of the viewport.
8
- * `{x:0,y:0}` top-left (default); `{x:0.5,y:0.5}` centre. */
8
+ * `{x:0,y:0}` top-left (default); `{x:0.5,y:0.5}` center. */
9
9
  origin?: Point;
10
10
  /** Which way the world y axis runs on screen. Default `'down'`. */
11
11
  yAxis?: 'down' | 'up';
@@ -25,8 +25,10 @@ export interface WorldFrame {
25
25
  yDir: 1 | -1;
26
26
  }
27
27
 
28
+ /** The frame an absent `WorldSpec` resolves to: origin top-left, y down. */
28
29
  export const DEFAULT_FRAME: WorldFrame = { originPx: { x: 0, y: 0 }, yDir: 1 };
29
30
 
31
+ /** Resolve `spec` against a viewport of `size`; `undefined` gives `DEFAULT_FRAME`. */
30
32
  export function resolveFrame(spec: WorldSpec | undefined, size: ViewportSize): WorldFrame {
31
33
  if (!spec) return DEFAULT_FRAME;
32
34
  const origin = spec.origin ?? { x: 0, y: 0 };
@@ -72,6 +72,7 @@ export default meta;
72
72
  type Story = StoryObj<typeof Lab>;
73
73
 
74
74
  export const EveryRegion: Story = {
75
+ tags: ['gallery'],
75
76
  args: {
76
77
  instruments: [FullInstrument],
77
78
  defaultInstrument: 'Every Region',
@@ -23,6 +23,7 @@ const ctx: TrialChromeContext = {
23
23
  canRedo: false,
24
24
  undo: () => {},
25
25
  redo: () => {},
26
+ hasLoupe: false,
26
27
  loupeOn: false,
27
28
  toggleLoupe: () => {},
28
29
  configFields: [],
@@ -72,21 +73,21 @@ describe('builtinContributions', () => {
72
73
  expect(ids({ ...bare, undo: {} })).toContain('redo');
73
74
  });
74
75
 
75
- it('contributes the loupe toggle only when the instrument declares one', () => {
76
+ it('contributes the loupe toggle only while a lens is mounted', () => {
76
77
  expect(ids(bare)).not.toContain('loupe');
77
- expect(ids({ ...bare, loupe: true })).toContain('loupe');
78
- expect(ids({ ...bare, loupe: { render: () => null } })).toContain('loupe');
78
+ const mounted = builtinContributions(bare, { ...ctx, hasLoupe: true });
79
+ expect(mounted.map((c) => c.id)).toContain('loupe');
79
80
  });
80
81
 
81
82
  it('offers the loupe to a DOM instrument, which declares no canvas', () => {
82
- const contributions = builtinContributions({ ...bare, loupe: true }, ctx);
83
+ const contributions = builtinContributions(bare, { ...ctx, hasLoupe: true });
83
84
  const loupe = contributions.find((c) => c.id === 'loupe');
84
85
  expect(loupe?.region).toBe('toolbar');
85
86
  });
86
87
 
87
88
  it('reports the loupe switch position, so the button reads as held', () => {
88
- const off = builtinContributions({ ...bare, loupe: true }, ctx);
89
- const on = builtinContributions({ ...bare, loupe: true }, { ...ctx, loupeOn: true });
89
+ const off = builtinContributions(bare, { ...ctx, hasLoupe: true });
90
+ const on = builtinContributions(bare, { ...ctx, hasLoupe: true, loupeOn: true });
90
91
  expect(off.find((c) => c.id === 'loupe')?.item).toMatchObject({ pressed: false });
91
92
  expect(on.find((c) => c.id === 'loupe')?.item).toMatchObject({ pressed: true });
92
93
  });
@@ -39,6 +39,10 @@ function groupFolds(
39
39
  return out;
40
40
  }
41
41
 
42
+ /** Built-in ids contributed only while runtime state says so — the loupe
43
+ * toggle appears once a lens mounts, after the trial's first render. */
44
+ export const TRANSIENT_BUILTINS: ReadonlySet<string> = new Set(['loupe']);
45
+
42
46
  /**
43
47
  * The contributions a trial gets from what its instrument declared. This is
44
48
  * the whole "declaring a capability provides the chrome" rule — it replaces
@@ -93,7 +97,7 @@ export function builtinContributions(
93
97
  });
94
98
  }
95
99
 
96
- if (instrument.loupe != null) {
100
+ if (ctx.hasLoupe) {
97
101
  out.push({
98
102
  id: 'loupe',
99
103
  region: 'toolbar',
@@ -1,4 +1,4 @@
1
- export { builtinContributions } from './builtins';
1
+ export { builtinContributions, TRANSIENT_BUILTINS } from './builtins';
2
2
  export type { LabRegionProps } from './LabChrome';
3
3
  export {
4
4
  contributionsIn,
@@ -44,6 +44,11 @@ describe('suppressContributions', () => {
44
44
  );
45
45
  });
46
46
 
47
+ it('passes over an absent id it was told may come and go', () => {
48
+ const out = suppressContributions([toolbarItem('a')], ['late'], new Set(['late']));
49
+ expect(out.map((c) => c.id)).toEqual(['a']);
50
+ });
51
+
47
52
  it('is a no-op for an empty suppress list', () => {
48
53
  const bundle = [toolbarItem('a')];
49
54
  expect(suppressContributions(bundle, [])).toEqual(bundle);
@@ -30,15 +30,17 @@ export function mergeContributions<T extends { id: string }>(
30
30
  /**
31
31
  * Drop contributions by id. Throws when an id is not present: a typo that
32
32
  * silently suppresses nothing is the same class of bug as a duplicate id
33
- * silently winning.
33
+ * silently winning. `transient` names ids that are legitimately absent at
34
+ * times — contributed only while some runtime state holds.
34
35
  */
35
36
  export function suppressContributions<T extends { id: string }>(
36
37
  bundle: readonly T[],
37
38
  ids: readonly string[],
39
+ transient: ReadonlySet<string> = new Set(),
38
40
  ): T[] {
39
41
  const present = new Set(bundle.map((c) => c.id));
40
42
  for (const id of ids) {
41
- if (!present.has(id)) {
43
+ if (!present.has(id) && !transient.has(id)) {
42
44
  throw new Error(
43
45
  `[labkit] cannot suppress "${id}": no contribution with that id. ` +
44
46
  `Present ids: ${[...present].join(', ')}`,
@@ -4,11 +4,4 @@
4
4
  padding: var(--wzl-space-xs);
5
5
  border-right: var(--wzl-border-w) solid var(--wzl-line-subtle);
6
6
  background: var(--wzl-surface-sunken);
7
-
8
- // labkit's `:where(button)` default sets an explicit height, and because
9
- // :where() carries no specificity it also wins over ToolButton, which sizes
10
- // itself from padding.
11
- button {
12
- height: auto;
13
- }
14
7
  }
@@ -62,16 +62,15 @@ describe('PaletteRegion', () => {
62
62
 
63
63
  it('walks the vertical strip with ArrowDown, and leaves ArrowRight to the page', () => {
64
64
  render(<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith(null)} />);
65
- const strip = screen.getByRole('toolbar', { name: 'Tools' });
66
65
  const brush = screen.getByRole('button', { name: 'brush' });
67
66
  const eraser = screen.getByRole('button', { name: 'eraser' });
68
67
 
69
68
  brush.focus();
70
- fireEvent.keyDown(strip, { key: 'ArrowDown' });
69
+ fireEvent.keyDown(brush, { key: 'ArrowDown' });
71
70
  expect(document.activeElement).toBe(eraser);
72
71
  expect(eraser).toHaveAttribute('tabindex', '0');
73
72
 
74
- fireEvent.keyDown(strip, { key: 'ArrowRight' });
73
+ fireEvent.keyDown(eraser, { key: 'ArrowRight' });
75
74
  expect(document.activeElement).toBe(eraser);
76
75
  });
77
76
  });
@@ -1,5 +1,4 @@
1
- import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
2
- import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
1
+ import { ToolButton, ToolGroup, useRovingTabIndex } from '../../passthrough/weasel-ui';
3
2
  import type { RegionContribution, ToolItem, ToolSlotContext, TrialChromeContext } from '../types';
4
3
 
5
4
  /** Props for `<PaletteRegion>`. */
@@ -19,11 +18,11 @@ export function PaletteRegion<TCtx extends ToolSlotContext = TrialChromeContext>
19
18
  ctx,
20
19
  region = 'palette',
21
20
  }: PaletteRegionProps<TCtx>) {
22
- const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>('vertical');
21
+ const { rootRef, onKeyDown } = useRovingTabIndex<HTMLDivElement>({ orientation: 'vertical' });
23
22
  if (contributions.length === 0) return null;
24
23
  return (
25
24
  <div
26
- ref={ref}
25
+ ref={rootRef}
27
26
  className="lk-palette-region"
28
27
  role="toolbar"
29
28
  aria-label="Tools"
@@ -23,6 +23,7 @@
23
23
  --_s-tone: var(--wzl-tone, var(--_s-surface));
24
24
  --_s-accent: var(--wzl-tone);
25
25
  --_s-title-font: var(--wzl-font-display);
26
+ --_s-title-weight: var(--wzl-font-weight-bold);
26
27
  --_s-title-size: var(--wzl-font-size-sm);
27
28
  --_s-title-case: uppercase;
28
29
  --_s-title-tracking: 0.08em;
@@ -36,6 +37,7 @@
36
37
  --_s-tone: var(--wzl-tone, var(--wzl-stance-scope-tone, var(--_s-surface)));
37
38
  --_s-accent: var(--wzl-stance-scope-accent, var(--wzl-tone));
38
39
  --_s-title-font: var(--wzl-stance-scope-title-font, var(--wzl-font-display));
40
+ --_s-title-weight: var(--wzl-stance-scope-title-weight, var(--wzl-font-weight-bold));
39
41
  --_s-title-size: var(--wzl-stance-scope-title-size, var(--wzl-font-size-sm));
40
42
  --_s-title-case: var(--wzl-stance-scope-title-case, uppercase);
41
43
  --_s-title-tracking: var(--wzl-stance-scope-title-tracking, 0.08em);
@@ -49,6 +51,7 @@
49
51
  --_s-tone: var(--wzl-tone, var(--wzl-stance-aside-tone, var(--_s-surface)));
50
52
  --_s-accent: var(--wzl-stance-aside-accent, var(--wzl-tone));
51
53
  --_s-title-font: var(--wzl-stance-aside-title-font, var(--wzl-font-display));
54
+ --_s-title-weight: var(--wzl-stance-aside-title-weight, var(--wzl-font-weight-bold));
52
55
  --_s-title-size: var(--wzl-stance-aside-title-size, var(--wzl-font-size-sm));
53
56
  --_s-title-case: var(--wzl-stance-aside-title-case, uppercase);
54
57
  --_s-title-tracking: var(--wzl-stance-aside-title-tracking, 0.08em);
@@ -62,6 +65,7 @@
62
65
  --_s-tone: var(--wzl-tone, var(--wzl-stance-advanced-tone, var(--_s-surface)));
63
66
  --_s-accent: var(--wzl-stance-advanced-accent, var(--wzl-tone));
64
67
  --_s-title-font: var(--wzl-stance-advanced-title-font, var(--wzl-font-display));
68
+ --_s-title-weight: var(--wzl-stance-advanced-title-weight, var(--wzl-font-weight-bold));
65
69
  --_s-title-size: var(--wzl-stance-advanced-title-size, var(--wzl-font-size-sm));
66
70
  --_s-title-case: var(--wzl-stance-advanced-title-case, uppercase);
67
71
  --_s-title-tracking: var(--wzl-stance-advanced-title-tracking, 0.08em);
@@ -75,6 +79,7 @@
75
79
  --_s-tone: var(--wzl-tone, var(--wzl-stance-debug-tone, var(--_s-surface)));
76
80
  --_s-accent: var(--wzl-stance-debug-accent, var(--wzl-tone));
77
81
  --_s-title-font: var(--wzl-stance-debug-title-font, var(--wzl-font-display));
82
+ --_s-title-weight: var(--wzl-stance-debug-title-weight, var(--wzl-font-weight-bold));
78
83
  --_s-title-size: var(--wzl-stance-debug-title-size, var(--wzl-font-size-sm));
79
84
  --_s-title-case: var(--wzl-stance-debug-title-case, uppercase);
80
85
  --_s-title-tracking: var(--wzl-stance-debug-title-tracking, 0.08em);
@@ -88,6 +93,7 @@
88
93
  --_s-tone: var(--wzl-tone, var(--wzl-stance-danger-tone, var(--_s-surface)));
89
94
  --_s-accent: var(--wzl-stance-danger-accent, var(--wzl-tone));
90
95
  --_s-title-font: var(--wzl-stance-danger-title-font, var(--wzl-font-display));
96
+ --_s-title-weight: var(--wzl-stance-danger-title-weight, var(--wzl-font-weight-bold));
91
97
  --_s-title-size: var(--wzl-stance-danger-title-size, var(--wzl-font-size-sm));
92
98
  --_s-title-case: var(--wzl-stance-danger-title-case, uppercase);
93
99
  --_s-title-tracking: var(--wzl-stance-danger-title-tracking, 0.08em);
@@ -101,6 +107,7 @@
101
107
  --_s-tone: var(--wzl-tone, var(--wzl-stance-notice-tone, var(--_s-surface)));
102
108
  --_s-accent: var(--wzl-stance-notice-accent, var(--wzl-tone));
103
109
  --_s-title-font: var(--wzl-stance-notice-title-font, var(--wzl-font-display));
110
+ --_s-title-weight: var(--wzl-stance-notice-title-weight, var(--wzl-font-weight-bold));
104
111
  --_s-title-size: var(--wzl-stance-notice-title-size, var(--wzl-font-size-sm));
105
112
  --_s-title-case: var(--wzl-stance-notice-title-case, uppercase);
106
113
  --_s-title-tracking: var(--wzl-stance-notice-title-tracking, 0.08em);
@@ -114,6 +121,7 @@
114
121
  --_s-tone: var(--wzl-tone, var(--wzl-stance-important-tone, var(--_s-surface)));
115
122
  --_s-accent: var(--wzl-stance-important-accent, var(--wzl-tone));
116
123
  --_s-title-font: var(--wzl-stance-important-title-font, var(--wzl-font-display));
124
+ --_s-title-weight: var(--wzl-stance-important-title-weight, var(--wzl-font-weight-bold));
117
125
  --_s-title-size: var(--wzl-stance-important-title-size, var(--wzl-font-size-sm));
118
126
  --_s-title-case: var(--wzl-stance-important-title-case, uppercase);
119
127
  --_s-title-tracking: var(--wzl-stance-important-title-tracking, 0.08em);
@@ -127,6 +135,7 @@
127
135
  --_s-tone: var(--wzl-tone, var(--wzl-stance-preview-tone, var(--_s-surface)));
128
136
  --_s-accent: var(--wzl-stance-preview-accent, var(--wzl-tone));
129
137
  --_s-title-font: var(--wzl-stance-preview-title-font, var(--wzl-font-display));
138
+ --_s-title-weight: var(--wzl-stance-preview-title-weight, var(--wzl-font-weight-bold));
130
139
  --_s-title-size: var(--wzl-stance-preview-title-size, var(--wzl-font-size-sm));
131
140
  --_s-title-case: var(--wzl-stance-preview-title-case, uppercase);
132
141
  --_s-title-tracking: var(--wzl-stance-preview-title-tracking, 0.08em);
@@ -174,12 +183,13 @@
174
183
  .lk-sidebar-section__head {
175
184
  flex: 1;
176
185
  width: 100%;
177
- padding: var(--wzl-space-xs);
186
+ padding: var(--wzl-space-xs) var(--wzl-space-sm);
178
187
  border: 0;
179
188
  background: transparent;
180
189
  color: var(--_s-title-color);
181
190
  font-family: var(--_s-title-font);
182
191
  font-size: var(--_s-title-size);
192
+ font-weight: var(--_s-title-weight);
183
193
  letter-spacing: var(--_s-title-tracking);
184
194
  text-align: left;
185
195
  text-transform: var(--_s-title-case);
@@ -193,5 +203,5 @@
193
203
  // The section body is the only thing that insets a panel — the panels
194
204
  // themselves carry no padding, so this is the whole gutter.
195
205
  .lk-sidebar-section__body {
196
- padding: 0 var(--wzl-space-xs) var(--wzl-space-xs);
206
+ padding: 0 var(--wzl-space-sm) var(--wzl-space-sm);
197
207
  }
@@ -0,0 +1,6 @@
1
+ // Narrower than the zoom control's 160px natural width, wider than its floor.
2
+ .lk-viewport-test-well {
3
+ position: relative;
4
+ width: 160px;
5
+ height: 120px;
6
+ }
@@ -0,0 +1,38 @@
1
+ import '@weasel-js/theme/tokens.css';
2
+ import '../../styles.less';
3
+ import './ViewportRegion.browser.test.less';
4
+ import { cleanup, render } from '@testing-library/react';
5
+ import { afterEach, expect, test } from 'vitest';
6
+ import { ZoomControl } from '../../primitives/ZoomControl';
7
+ import type { TrialChromeContext, TrialContribution } from '../types';
8
+ import { ViewportRegion } from './ViewportRegion';
9
+
10
+ // Whether a rendered control shrinks to the cluster or runs out past it is
11
+ // layout, which only a real browser has.
12
+
13
+ afterEach(cleanup);
14
+
15
+ const ctx = { zoom: 1, setZoom: () => {} } as unknown as TrialChromeContext;
16
+
17
+ const contributions: TrialContribution[] = [
18
+ {
19
+ id: 'zoom-control',
20
+ region: 'viewport',
21
+ render: (c) => <ZoomControl zoom={c.zoom ?? 1} onZoomChange={c.setZoom} />,
22
+ },
23
+ ];
24
+
25
+ test('a zoom control on its own row shrinks its slider to fit a narrow well', () => {
26
+ const { container } = render(
27
+ <div className="lk-root">
28
+ <div className="lk-viewport-test-well">
29
+ <ViewportRegion contributions={contributions} ctx={ctx} />
30
+ </div>
31
+ </div>,
32
+ );
33
+ const cluster = container.querySelector('.lk-viewport-controls')?.getBoundingClientRect();
34
+ const zoom = container.querySelector('.lk-zoom')?.getBoundingClientRect();
35
+ if (!cluster || !zoom) throw new Error('nothing rendered');
36
+ expect(zoom.left).toBeGreaterThanOrEqual(cluster.left);
37
+ expect(zoom.right).toBeLessThanOrEqual(cluster.right);
38
+ });
@@ -17,6 +17,12 @@
17
17
  backdrop-filter: blur(var(--wzl-glass-blur));
18
18
  }
19
19
 
20
+ // Without this a rendered control's floor is its content's min-content, and the
21
+ // zoom slider's fixed width then runs it out past the cluster on a narrow well.
22
+ .lk-viewport-controls__item {
23
+ min-width: 0;
24
+ }
25
+
20
26
  .lk-viewport-controls__button {
21
27
  display: inline-flex;
22
28
  align-items: center;
@@ -1,4 +1,4 @@
1
- import { useRovingTabIndex } from '../../primitives/useRovingTabIndex';
1
+ import { useRovingTabIndex } from '../../passthrough/weasel-ui';
2
2
  import type { TrialChromeContext, TrialContribution } from '../types';
3
3
 
4
4
  /** Props for `<ViewportRegion>`. */
@@ -12,18 +12,24 @@ export interface ViewportRegionProps {
12
12
  * rather than in the toolbar, which acts on the trial itself.
13
13
  */
14
14
  export function ViewportRegion({ contributions, ctx }: ViewportRegionProps) {
15
- const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();
15
+ const { rootRef, onKeyDown } = useRovingTabIndex<HTMLDivElement>({ orientation: 'horizontal' });
16
16
  if (contributions.length === 0) return null;
17
17
  return (
18
18
  <div
19
- ref={ref}
19
+ ref={rootRef}
20
20
  className="lk-viewport-controls"
21
21
  role="toolbar"
22
22
  aria-label="View"
23
23
  onKeyDown={onKeyDown}
24
24
  >
25
25
  {contributions.map((c) => {
26
- if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
26
+ if (c.render) {
27
+ return (
28
+ <span key={c.id} className="lk-viewport-controls__item">
29
+ {c.render(ctx)}
30
+ </span>
31
+ );
32
+ }
27
33
  if (c.region !== 'viewport' || !c.item) return null;
28
34
  const { icon: Icon, label, disabled, onActivate } = c.item;
29
35
  return (