@weasel-js/labkit 1.7.0 → 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 (208) hide show
  1. package/README.md +3 -3
  2. package/dist/_dts/{CanvasStackContext-D2jnIwfk.d.ts → CanvasStackContext-Cjy6I-Iv.d.ts} +4 -1
  3. package/dist/_dts/{Stage-CCWfQQWM.d.ts → Stage-D6t7o0pF.d.ts} +3 -2
  4. package/dist/_dts/{index-CsYqUHD3.d.ts → index-BTmaRy6a.d.ts} +10 -6
  5. package/dist/_dts/{types-CSm9jgol.d.ts → index-DCMA4Fy1.d.ts} +59 -113
  6. package/dist/_dts/{types-CRPSGFAp.d.ts → types-BUBSIZuV.d.ts} +8 -7
  7. package/dist/_dts/{useTrialState-CTZPgeZ7.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 +7 -5
  11. package/dist/chrome/index.js +10 -9
  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-7BO7HNGF.js → chunk-CEGFXTBD.js} +8 -61
  19. package/dist/chunk-CEGFXTBD.js.map +1 -0
  20. package/dist/{chunk-275JRRVD.js → chunk-E23Q6A3Y.js} +232 -37
  21. package/dist/chunk-E23Q6A3Y.js.map +1 -0
  22. package/dist/{chunk-GOZLNM4L.js → chunk-E6NARGU7.js} +5 -5
  23. package/dist/{chunk-GOZLNM4L.js.map → chunk-E6NARGU7.js.map} +1 -1
  24. package/dist/{chunk-IJJUK4FU.js → chunk-EESJUSYR.js} +5 -6
  25. package/dist/chunk-EESJUSYR.js.map +1 -0
  26. package/dist/{chunk-HXXTULDN.js → chunk-EFXRM6XQ.js} +8 -11
  27. package/dist/chunk-EFXRM6XQ.js.map +1 -0
  28. package/dist/chunk-F4ACIK7Z.js +210 -0
  29. package/dist/chunk-F4ACIK7Z.js.map +1 -0
  30. package/dist/{chunk-FMK7UQ6V.js → chunk-FB4MIZOO.js} +61 -44
  31. package/dist/chunk-FB4MIZOO.js.map +1 -0
  32. package/dist/{chunk-AWQG7FCC.js → chunk-FG3EXZDV.js} +3 -3
  33. package/dist/{chunk-AWQG7FCC.js.map → chunk-FG3EXZDV.js.map} +1 -1
  34. package/dist/{chunk-I5DUQ5HM.js → chunk-GAHELLJT.js} +75 -72
  35. package/dist/chunk-GAHELLJT.js.map +1 -0
  36. package/dist/{chunk-YT75VYRD.js → chunk-IBVRMHW5.js} +5 -5
  37. package/dist/{chunk-YT75VYRD.js.map → chunk-IBVRMHW5.js.map} +1 -1
  38. package/dist/{chunk-T5IBSPAM.js → chunk-JFOJESJF.js} +33 -23
  39. package/dist/chunk-JFOJESJF.js.map +1 -0
  40. package/dist/{chunk-RWNDDKOH.js → chunk-PMLZP7RT.js} +18 -15
  41. package/dist/chunk-PMLZP7RT.js.map +1 -0
  42. package/dist/{chunk-Y23G2VZL.js → chunk-READTDQX.js} +2 -2
  43. package/dist/chunk-READTDQX.js.map +1 -0
  44. package/dist/{chunk-JVBYV6TL.js → chunk-ROGJWDQA.js} +3 -3
  45. package/dist/{chunk-JVBYV6TL.js.map → chunk-ROGJWDQA.js.map} +1 -1
  46. package/dist/{chunk-ZIFPHIWZ.js → chunk-T6DLGEJB.js} +12 -14
  47. package/dist/chunk-T6DLGEJB.js.map +1 -0
  48. package/dist/{chunk-24W5VEJW.js → chunk-ULYYNWGP.js} +2 -2
  49. package/dist/{chunk-24W5VEJW.js.map → chunk-ULYYNWGP.js.map} +1 -1
  50. package/dist/{chunk-UNAZSLWE.js → chunk-VYVGVBGY.js} +17 -20
  51. package/dist/chunk-VYVGVBGY.js.map +1 -0
  52. package/dist/{chunk-KMJ7RWD3.js → chunk-WLMDTI2Q.js} +3 -3
  53. package/dist/{chunk-KMJ7RWD3.js.map → chunk-WLMDTI2Q.js.map} +1 -1
  54. package/dist/{chunk-J7OWWTCW.js → chunk-XE5HJOPL.js} +5 -6
  55. package/dist/chunk-XE5HJOPL.js.map +1 -0
  56. package/dist/chunk-XVUKDSYV.js +38 -0
  57. package/dist/chunk-XVUKDSYV.js.map +1 -0
  58. package/dist/{chunk-FZ7YFQN7.js → chunk-YCFKW5RX.js} +3 -3
  59. package/dist/chunk-YCFKW5RX.js.map +1 -0
  60. package/dist/{chunk-CQLQPQ4P.js → chunk-ZQIHJXP4.js} +2 -2
  61. package/dist/chunk-ZQIHJXP4.js.map +1 -0
  62. package/dist/config/index.d.ts +18 -10
  63. package/dist/config/index.js +4 -4
  64. package/dist/controls/index.d.ts +4 -7
  65. package/dist/controls/index.js +6 -5
  66. package/dist/dragdrop/index.d.ts +23 -3
  67. package/dist/dragdrop/index.js +4 -3
  68. package/dist/fonts/oswald-tabular-digits.woff2 +0 -0
  69. package/dist/index.d.ts +60 -714
  70. package/dist/index.js +357 -334
  71. package/dist/index.js.map +1 -1
  72. package/dist/job/index.d.ts +8 -0
  73. package/dist/job/index.js +1 -1
  74. package/dist/layers/index.d.ts +6 -5
  75. package/dist/layers/index.js +3 -2
  76. package/dist/loupe/index.d.ts +100 -30
  77. package/dist/loupe/index.js +585 -6
  78. package/dist/loupe/index.js.map +1 -1
  79. package/dist/overview/index.d.ts +4 -2
  80. package/dist/overview/index.js +15 -14
  81. package/dist/overview/index.js.map +1 -1
  82. package/dist/passthrough/weasel-canvas.d.ts +2 -0
  83. package/dist/passthrough/weasel-ui.d.ts +2 -2871
  84. package/dist/passthrough/weasel-ui.js +3 -2
  85. package/dist/primitives/index.d.ts +213 -3
  86. package/dist/primitives/index.js +8 -7
  87. package/dist/state/index.d.ts +7 -5
  88. package/dist/state/index.js +6 -6
  89. package/dist/styles.css +60 -44
  90. package/dist/surface/index.d.ts +10 -2
  91. package/dist/surface/index.js +1 -1
  92. package/dist/undo/index.d.ts +5 -3
  93. package/package.json +11 -11
  94. package/src/annotations/AnnotationOverlay.abandon.test.tsx +143 -0
  95. package/src/annotations/AnnotationOverlay.tsx +50 -31
  96. package/src/annotations/Annotations.overlay.test.tsx +31 -0
  97. package/src/annotations/capture.browser.test.ts +41 -0
  98. package/src/annotations/capture.ts +14 -7
  99. package/src/annotations/drawOne.ts +2 -2
  100. package/src/annotations/frac.test.ts +44 -1
  101. package/src/annotations/frac.ts +46 -18
  102. package/src/annotations/index.ts +1 -0
  103. package/src/annotations/paint.test.ts +74 -33
  104. package/src/annotations/paint.ts +70 -32
  105. package/src/annotations/store.test.ts +212 -2
  106. package/src/annotations/store.ts +130 -27
  107. package/src/annotations/svgNodes.test.ts +22 -10
  108. package/src/annotations/svgNodes.ts +3 -3
  109. package/src/annotations/types.ts +29 -10
  110. package/src/canvas/CameraInput.test.tsx +30 -0
  111. package/src/canvas/CameraInput.tsx +16 -15
  112. package/src/canvas/CanvasStack.tsx +5 -5
  113. package/src/canvas/CanvasStackContext.ts +4 -1
  114. package/src/canvas/Stage.tsx +3 -3
  115. package/src/canvas/cameraRegistry.ts +1 -0
  116. package/src/canvas/worldSpec.ts +3 -1
  117. package/src/chrome/builtins.test.ts +7 -6
  118. package/src/chrome/builtins.tsx +5 -1
  119. package/src/chrome/index.ts +1 -1
  120. package/src/chrome/merge.test.ts +5 -0
  121. package/src/chrome/merge.ts +4 -2
  122. package/src/chrome/regions/PaletteRegion.test.tsx +2 -3
  123. package/src/chrome/regions/PaletteRegion.tsx +3 -4
  124. package/src/chrome/regions/ViewportRegion.browser.test.less +6 -0
  125. package/src/chrome/regions/ViewportRegion.browser.test.tsx +38 -0
  126. package/src/chrome/regions/ViewportRegion.less +6 -0
  127. package/src/chrome/regions/ViewportRegion.tsx +10 -4
  128. package/src/chrome/types.ts +4 -2
  129. package/src/config/auto.ts +1 -0
  130. package/src/config/builder.ts +6 -0
  131. package/src/config/types.ts +2 -1
  132. package/src/controls/ControlPanel.test.tsx +37 -3
  133. package/src/controls/ControlPanel.tsx +12 -3
  134. package/src/dragdrop/DragDropRuntime.tsx +22 -13
  135. package/src/dragdrop/DragGhost.tsx +3 -0
  136. package/src/dragdrop/Palette.tsx +2 -0
  137. package/src/index.test.ts +5 -0
  138. package/src/index.ts +1 -1
  139. package/src/instrument/types.ts +0 -6
  140. package/src/job/useJob.ts +14 -9
  141. package/src/lab/Lab.tsx +5 -1
  142. package/src/lab/LabShell.less +1 -0
  143. package/src/lab/LabSwitcher.browser.test.tsx +27 -0
  144. package/src/lab/LabSwitcher.less +2 -0
  145. package/src/lab/LabZoom.tsx +4 -4
  146. package/src/lab/Workspace.tsx +48 -52
  147. package/src/loupe/AGENTS.md +28 -15
  148. package/src/loupe/CanvasLoupe.tsx +2 -3
  149. package/src/loupe/DomLoupe.tsx +1 -7
  150. package/src/loupe/Loupe.less +16 -0
  151. package/src/loupe/TrialLoupe.tsx +120 -57
  152. package/src/loupe/index.ts +1 -6
  153. package/src/loupe/types.test.ts +3 -3
  154. package/src/loupe/types.ts +24 -27
  155. package/src/loupe/useLoupe.test.tsx +29 -4
  156. package/src/loupe/useLoupe.ts +20 -17
  157. package/src/overview/OverviewMarks.tsx +12 -4
  158. package/src/overview/TrialOverview.tsx +3 -3
  159. package/src/passthrough/weasel-ui.test.ts +1 -1
  160. package/src/passthrough/weasel-ui.ts +1 -1
  161. package/src/primitives/FloatingPanel.tsx +7 -3
  162. package/src/primitives/JobProgress.less +1 -0
  163. package/src/primitives/Readout.test.tsx +2 -2
  164. package/src/primitives/Split.tsx +69 -41
  165. package/src/primitives/Toolbar.tsx +3 -3
  166. package/src/primitives/ZoomControl.less +1 -2
  167. package/src/specimen/Specimen.tsx +0 -1
  168. package/src/state/undock.ts +3 -0
  169. package/src/state/useOpenOnce.ts +20 -16
  170. package/src/state/view.ts +1 -0
  171. package/src/surface/index.ts +1 -1
  172. package/src/surface/useTiledSurface.test.tsx +14 -0
  173. package/src/surface/useTiledSurface.ts +11 -3
  174. package/src/trial/Trial.annotations.test.tsx +73 -0
  175. package/src/trial/Trial.less +0 -16
  176. package/src/trial/Trial.loupe.test.tsx +28 -11
  177. package/src/trial/Trial.tsx +74 -62
  178. package/src/trial/TrialChrome.tsx +6 -3
  179. package/src/trial/index.ts +2 -0
  180. package/src/trial/loupeSwitch.ts +15 -0
  181. package/dist/_dts/LayerList.d-DiuEArKq.d.ts +0 -83
  182. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +0 -79
  183. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +0 -141
  184. package/dist/_dts/Select.d-Bensr_MT.d.ts +0 -579
  185. package/dist/_dts/index-B2Yj5aA0.d.ts +0 -274
  186. package/dist/chunk-275JRRVD.js.map +0 -1
  187. package/dist/chunk-4LJSC7MB.js +0 -143
  188. package/dist/chunk-4LJSC7MB.js.map +0 -1
  189. package/dist/chunk-7BO7HNGF.js.map +0 -1
  190. package/dist/chunk-CQLQPQ4P.js.map +0 -1
  191. package/dist/chunk-FMK7UQ6V.js.map +0 -1
  192. package/dist/chunk-FZ7YFQN7.js.map +0 -1
  193. package/dist/chunk-HXXTULDN.js.map +0 -1
  194. package/dist/chunk-I5DUQ5HM.js.map +0 -1
  195. package/dist/chunk-IJJUK4FU.js.map +0 -1
  196. package/dist/chunk-J7OWWTCW.js.map +0 -1
  197. package/dist/chunk-M7HP3FTK.js +0 -549
  198. package/dist/chunk-M7HP3FTK.js.map +0 -1
  199. package/dist/chunk-MIM5R4ZT.js +0 -14972
  200. package/dist/chunk-MIM5R4ZT.js.map +0 -1
  201. package/dist/chunk-RWNDDKOH.js.map +0 -1
  202. package/dist/chunk-T5IBSPAM.js.map +0 -1
  203. package/dist/chunk-UNAZSLWE.js.map +0 -1
  204. package/dist/chunk-WPLAANH4.js.map +0 -1
  205. package/dist/chunk-Y23G2VZL.js.map +0 -1
  206. package/dist/chunk-ZIFPHIWZ.js.map +0 -1
  207. package/src/primitives/useRovingTabIndex.test.ts +0 -48
  208. package/src/primitives/useRovingTabIndex.ts +0 -88
@@ -15,6 +15,7 @@ export interface CameraRegistry {
15
15
  subscribe(listener: () => void): () => void;
16
16
  }
17
17
 
18
+ /** An empty in-memory `CameraRegistry`. */
18
19
  export function createCameraRegistry(): CameraRegistry {
19
20
  const stacks = new Map<string, CameraView[]>();
20
21
  const listeners = new Set<() => void>();
@@ -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 };
@@ -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(', ')}`,
@@ -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"
@@ -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 (
@@ -162,8 +162,10 @@ export interface TrialChromeContext extends ToolSlotContext, SidebarSlotContext
162
162
  undo: () => void;
163
163
  redo: () => void;
164
164
 
165
- /** Whether the trial's loupe is turned on. False for an instrument that
166
- * declares none, whose chrome offers no way to turn one on. */
165
+ /** Whether a `<TrialLoupe>` following the trial's toggle is mounted in it.
166
+ * The loupe toggle is offered only while one is. */
167
+ hasLoupe: boolean;
168
+ /** Whether the trial's loupe is turned on. */
167
169
  loupeOn: boolean;
168
170
  toggleLoupe: () => void;
169
171
 
@@ -10,6 +10,7 @@ export const auto: unique symbol = Symbol('weasel.auto');
10
10
  /** The type of the `auto` sentinel, for widening a value parameter. */
11
11
  export type Auto = typeof auto;
12
12
 
13
+ /** Whether `value` is the `auto` sentinel. */
13
14
  export function isAuto(value: unknown): value is Auto {
14
15
  return value === auto;
15
16
  }
@@ -124,6 +124,7 @@ export abstract class BaseNode<T> implements ConfigNode<T> {
124
124
  }
125
125
  }
126
126
 
127
+ /** A numeric leaf, built by `f.number`. */
127
128
  export class NumberNode extends BaseNode<number> {
128
129
  readonly kind = 'number';
129
130
 
@@ -169,6 +170,7 @@ export class NumberNode extends BaseNode<number> {
169
170
  }
170
171
  }
171
172
 
173
+ /** A true/false leaf, built by `f.boolean`; a checkbox unless `.toggle()`. */
172
174
  export class BooleanNode extends BaseNode<boolean> {
173
175
  readonly kind = 'boolean';
174
176
 
@@ -178,6 +180,7 @@ export class BooleanNode extends BaseNode<boolean> {
178
180
  }
179
181
  }
180
182
 
183
+ /** A text leaf, built by `f.string`. */
181
184
  export class StringNode extends BaseNode<string> {
182
185
  readonly kind = 'string';
183
186
 
@@ -195,6 +198,7 @@ export class StringNode extends BaseNode<string> {
195
198
  }
196
199
  }
197
200
 
201
+ /** A string-list leaf, built by `f.list`. */
198
202
  export class ListNode extends BaseNode<string[]> {
199
203
  readonly kind = 'list';
200
204
 
@@ -204,6 +208,7 @@ export class ListNode extends BaseNode<string[]> {
204
208
  }
205
209
  }
206
210
 
211
+ /** A color leaf holding a hex string, built by `f.color`. */
207
212
  export class ColorNode extends BaseNode<string> {
208
213
  readonly kind = 'color';
209
214
 
@@ -214,6 +219,7 @@ export class ColorNode extends BaseNode<string> {
214
219
  }
215
220
  }
216
221
 
222
+ /** A fixed-choice leaf, built by `f.enum`; a select unless `.radio()`. */
217
223
  export class EnumNode<T extends string> extends BaseNode<T> {
218
224
  readonly kind = 'enum';
219
225
 
@@ -14,7 +14,8 @@ export interface ConfigOption {
14
14
  value: string;
15
15
  label: string;
16
16
  /** A glyph in weasel-ui's icon set (`IconName`). A segmented row draws it in
17
- * place of `label`, which stays the accessible name and becomes the tooltip. */
17
+ * place of `label`, which stays the accessible name and becomes the tooltip;
18
+ * a select draws it beside `label`. */
18
19
  icon?: string;
19
20
  }
20
21
 
@@ -919,6 +919,30 @@ describe('<ControlPanel> option icons', () => {
919
919
  expect(ok.textContent).toBe('');
920
920
  expect(screen.getByRole('radio', { name: 'bad' }).textContent).toBe('bad');
921
921
  });
922
+
923
+ it("draws an option's icon beside its label in a select's trigger and list", () => {
924
+ const status = f.enum('ok', [
925
+ { value: 'ok', label: 'ok', icon: 'statusSuccess' },
926
+ { value: 'bad', label: 'bad' },
927
+ ]);
928
+ render(
929
+ <ControlPanel
930
+ schema={resolveConfigSchema(f.schema({ status }), [])}
931
+ config={{ status: 'ok' }}
932
+ setConfig={() => {}}
933
+ />,
934
+ );
935
+ const trigger = screen.getByRole('button', { name: /ok/ });
936
+ expect(trigger.querySelector('[aria-hidden="true"] > svg')).not.toBeNull();
937
+ expect(trigger).toHaveTextContent('ok');
938
+ act(() => {
939
+ fireEvent.click(trigger);
940
+ });
941
+ expect(
942
+ screen.getByRole('option', { name: 'ok' }).querySelector('[aria-hidden="true"] > svg'),
943
+ ).not.toBeNull();
944
+ expect(screen.getByRole('option', { name: 'bad' }).querySelectorAll('svg')).toHaveLength(1);
945
+ });
922
946
  });
923
947
 
924
948
  describe('<ControlPanel> pair', () => {
@@ -1129,14 +1153,20 @@ describe('<ControlPanel> resettable group', () => {
1129
1153
  const schema = resolveConfigSchema(
1130
1154
  f.schema({
1131
1155
  name: f.string('a'),
1132
- modes: f.group({ one: f.number(0), two: f.number(0), inner: f.group({ three: f.number(1) }) }).resettable(),
1156
+ modes: f
1157
+ .group({ one: f.number(0), two: f.number(0), inner: f.group({ three: f.number(1) }) })
1158
+ .resettable(),
1133
1159
  plain: f.group({ four: f.number(0) }),
1134
1160
  }),
1135
1161
  );
1136
1162
 
1137
1163
  it('puts a reset button on the group heading, disabled while nothing has changed', () => {
1138
1164
  render(
1139
- <ControlPanel schema={schema} config={{ name: 'a', modes: { one: 0, two: 0, inner: { three: 1 } }, plain: { four: 0 } }} setConfig={vi.fn()} />,
1165
+ <ControlPanel
1166
+ schema={schema}
1167
+ config={{ name: 'a', modes: { one: 0, two: 0, inner: { three: 1 } }, plain: { four: 0 } }}
1168
+ setConfig={vi.fn()}
1169
+ />,
1140
1170
  );
1141
1171
  expect(screen.getByRole('button', { name: 'Reset Modes' })).toBeDisabled();
1142
1172
  expect(screen.queryByRole('button', { name: 'Reset Plain' })).toBeNull();
@@ -1145,7 +1175,11 @@ describe('<ControlPanel> resettable group', () => {
1145
1175
  it('writes the default of every changed value beneath the group and nothing else', () => {
1146
1176
  const setConfig = vi.fn();
1147
1177
  render(
1148
- <ControlPanel schema={schema} config={{ name: 'b', modes: { one: 2, two: 0, inner: { three: 5 } }, plain: { four: 3 } }} setConfig={setConfig} />,
1178
+ <ControlPanel
1179
+ schema={schema}
1180
+ config={{ name: 'b', modes: { one: 2, two: 0, inner: { three: 5 } }, plain: { four: 3 } }}
1181
+ setConfig={setConfig}
1182
+ />,
1149
1183
  );
1150
1184
  fireEvent.click(screen.getByRole('button', { name: 'Reset Modes' }));
1151
1185
  expect(setConfig.mock.calls).toEqual([
@@ -123,7 +123,9 @@ const NO_AUTO: ReadonlySet<string> = new Set();
123
123
  function leavesUnder(group: PrefGroup, at: string): [string, unknown][] {
124
124
  return Object.entries(group.children).flatMap(([key, child]) => {
125
125
  const path = at === '' ? key : `${at}.${key}`;
126
- return isPrefLeaf(child) ? [[path, child.default] as [string, unknown]] : leavesUnder(child, path);
126
+ return isPrefLeaf(child)
127
+ ? [[path, child.default] as [string, unknown]]
128
+ : leavesUnder(child, path);
127
129
  });
128
130
  }
129
131
 
@@ -140,7 +142,9 @@ function ResetGroup({
140
142
  config: Record<string, unknown>;
141
143
  setConfig: (path: string, value: unknown) => void;
142
144
  }) {
143
- const changed = leavesUnder(group, path).filter(([p, d]) => !Object.is(valueAtPath(config, p), d));
145
+ const changed = leavesUnder(group, path).filter(
146
+ ([p, d]) => !Object.is(valueAtPath(config, p), d),
147
+ );
144
148
  return (
145
149
  <Button
146
150
  iconOnly
@@ -267,7 +271,12 @@ export function ControlPanel<TC extends Record<string, unknown>>({
267
271
  span
268
272
  actions={
269
273
  resolved.resettable?.has(path) ? (
270
- <ResetGroup group={found} path={path} config={config as Record<string, unknown>} setConfig={setConfig} />
274
+ <ResetGroup
275
+ group={found}
276
+ path={path}
277
+ config={config as Record<string, unknown>}
278
+ setConfig={setConfig}
279
+ />
271
280
  ) : undefined
272
281
  }
273
282
  {...fold(path, undefined, rows.grid)}
@@ -1,4 +1,5 @@
1
- import { openPointerSession, type PointerSession, useVisibleRaf } from '@weasel-js/core';
1
+ import { openPointerSession, type PointerSession, useLatest, useVisibleRaf } from '@weasel-js/core';
2
+ import { boxContainsPoint } from '@weasel-js/geom';
2
3
  import {
3
4
  type PointerEvent as ReactPointerEvent,
4
5
  type RefObject,
@@ -18,12 +19,16 @@ import type {
18
19
  } from '../instrument/types';
19
20
  import { DragGhost } from './DragGhost';
20
21
 
22
+ /** A palette drag in progress. `screenPos` is in client pixels; `feedback` is
23
+ * the last `onDragOver` answer — `null` off the canvas or without one. */
21
24
  export interface DragState {
22
25
  item: PaletteItem;
23
26
  screenPos: Point;
24
27
  feedback: DragFeedback | null;
25
28
  }
26
29
 
30
+ /** Inputs to `useDragDrop`: the capability, the element that counts as the
31
+ * drop target, and the trial state a drop rewrites. */
27
32
  export interface UseDragDropArgs<TS, TC> {
28
33
  capability: DragDropCapability<TS, TC>;
29
34
  canvasContainerRef: RefObject<HTMLElement | null>;
@@ -37,11 +42,18 @@ export interface UseDragDropArgs<TS, TC> {
37
42
  emit: (event: string) => void;
38
43
  }
39
44
 
45
+ /** The live drag, and the handler a palette item calls on pointerdown. */
40
46
  export interface UseDragDropResult {
41
47
  drag: DragState | null;
42
48
  startDrag: (item: PaletteItem, e: ReactPointerEvent) => void;
43
49
  }
44
50
 
51
+ /**
52
+ * Run palette-to-canvas drags for a `DragDropCapability`. Releasing over the
53
+ * container converts the pointer to world coordinates, applies `onDrop`'s
54
+ * state and emits `'canvas.itemAdded'`; releasing anywhere else, or a canceled
55
+ * gesture, drops nothing. `onDragOver` is probed at most once per frame.
56
+ */
45
57
  export function useDragDrop<TS, TC>({
46
58
  capability,
47
59
  canvasContainerRef,
@@ -53,8 +65,8 @@ export function useDragDrop<TS, TC>({
53
65
  worldSpec,
54
66
  }: UseDragDropArgs<TS, TC>): UseDragDropResult {
55
67
  const [drag, setDrag] = useState<DragState | null>(null);
68
+ // Owned by the handlers below, which write it alongside every `setDrag`.
56
69
  const dragRef = useRef<DragState | null>(null);
57
- dragRef.current = drag;
58
70
  /** The move the next frame will resolve. Doubles as the throttle's flag:
59
71
  * non-null means a frame is already queued. */
60
72
  const pendingPos = useRef<Point | null>(null);
@@ -62,20 +74,14 @@ export function useDragDrop<TS, TC>({
62
74
 
63
75
  /** A session keeps the closure it was opened with, so everything the drop
64
76
  * reads has to come from here rather than from that closure. */
65
- const liveRef = useRef({ capability, state, config, setState, emit, view, worldSpec });
66
- liveRef.current = { capability, state, config, setState, emit, view, worldSpec };
77
+ const liveRef = useLatest({ capability, state, config, setState, emit, view, worldSpec });
67
78
 
68
79
  const isOverCanvas = useCallback(
69
80
  (screenPos: Point): boolean => {
70
81
  const el = canvasContainerRef.current;
71
82
  if (!el) return false;
72
83
  const r = el.getBoundingClientRect();
73
- return (
74
- screenPos.x >= r.left &&
75
- screenPos.x <= r.right &&
76
- screenPos.y >= r.top &&
77
- screenPos.y <= r.bottom
78
- );
84
+ return boxContainsPoint([r.left, r.top, r.right, r.bottom], screenPos.x, screenPos.y);
79
85
  },
80
86
  [canvasContainerRef],
81
87
  );
@@ -92,7 +98,7 @@ export function useDragDrop<TS, TC>({
92
98
  frame,
93
99
  );
94
100
  },
95
- [canvasContainerRef],
101
+ [canvasContainerRef, liveRef],
96
102
  );
97
103
 
98
104
  const frameLoop = useVisibleRaf(() => {
@@ -106,7 +112,8 @@ export function useDragDrop<TS, TC>({
106
112
  const world = screenToWorldFromContainer(screenPos);
107
113
  if (world) feedback = live.capability.onDragOver(world, active.item, live.state, live.config);
108
114
  }
109
- setDrag({ ...active, screenPos, feedback });
115
+ dragRef.current = { ...active, screenPos, feedback };
116
+ setDrag(dragRef.current);
110
117
  });
111
118
 
112
119
  const clearDrag = useCallback(() => {
@@ -154,7 +161,7 @@ export function useDragDrop<TS, TC>({
154
161
  onCancel: clearDrag,
155
162
  });
156
163
  },
157
- [clearDrag, frameLoop, isOverCanvas, screenToWorldFromContainer],
164
+ [clearDrag, frameLoop, isOverCanvas, liveRef, screenToWorldFromContainer],
158
165
  );
159
166
 
160
167
  useEffect(() => () => sessionRef.current?.cancel(), []);
@@ -162,10 +169,12 @@ export function useDragDrop<TS, TC>({
162
169
  return { drag, startDrag };
163
170
  }
164
171
 
172
+ /** Props for `DragOverlay`. */
165
173
  export interface DragOverlayProps {
166
174
  drag: DragState | null;
167
175
  }
168
176
 
177
+ /** The ghost for `drag`, or nothing when no drag is live. */
169
178
  export function DragOverlay({ drag }: DragOverlayProps) {
170
179
  if (!drag) return null;
171
180
  return <DragGhost item={drag.item} screenPos={drag.screenPos} />;
@@ -2,11 +2,14 @@ import { type ReactPortal, useEffect, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import type { PaletteItem, Point } from '../instrument/types';
4
4
 
5
+ /** Props for `DragGhost`. `screenPos` is in client pixels. */
5
6
  export interface DragGhostProps {
6
7
  item: PaletteItem;
7
8
  screenPos: Point;
8
9
  }
9
10
 
11
+ /** The dragged item's label, portaled to `document.body` just below-right of
12
+ * `screenPos`. Renders nothing until mounted, so it is SSR-safe. */
10
13
  export function DragGhost({ item, screenPos }: DragGhostProps): ReactPortal | null {
11
14
  const [mounted, setMounted] = useState(false);
12
15
  useEffect(() => setMounted(true), []);
@@ -1,12 +1,14 @@
1
1
  import type { PointerEvent as ReactPointerEvent } from 'react';
2
2
  import type { PaletteItem } from '../instrument/types';
3
3
 
4
+ /** Props for `Palette`. `onDragStart` is usually `useDragDrop`'s `startDrag`. */
4
5
  export interface PaletteProps {
5
6
  items: PaletteItem[];
6
7
  onDragStart: (item: PaletteItem, e: ReactPointerEvent) => void;
7
8
  className?: string;
8
9
  }
9
10
 
11
+ /** One button per item, each starting a drag on pointerdown. */
10
12
  export function Palette({ items, onDragStart, className }: PaletteProps) {
11
13
  return (
12
14
  <div className={className ? `lk-palette ${className}` : 'lk-palette'}>
package/src/index.test.ts CHANGED
@@ -49,6 +49,11 @@ describe('public export surface', () => {
49
49
  expect(labkit).toHaveProperty('useTrialState');
50
50
  expect(labkit).toHaveProperty('useTrialId');
51
51
  });
52
+
53
+ it('leaves the loupe to its own entry, so a lab without one does not load it', () => {
54
+ expect(labkit).not.toHaveProperty('TrialLoupe');
55
+ expect(labkit).not.toHaveProperty('useLoupe');
56
+ });
52
57
  });
53
58
 
54
59
  describe('ui re-exports', () => {
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export type {
80
80
  AnnotationMeaning,
81
81
  AnnotationPatch,
82
82
  AnnotationQuery,
83
+ AnnotationSelectionMode,
83
84
  AnnotationStatus,
84
85
  AnnotationStoreOptions,
85
86
  AnnotationsApi,
@@ -267,7 +268,6 @@ export * from './job';
267
268
  export * from './lab';
268
269
  export type { LayerListItem, LayerListProps, LayerMove } from './layers';
269
270
  export { LayerList, moveLayers } from './layers';
270
- export * from './loupe';
271
271
  export * from './primitives';
272
272
  export { SPECIMEN_SECTIONS, Specimen } from './specimen/Specimen';
273
273
  export {
@@ -6,7 +6,6 @@ import type { TrialContribution } from '../chrome/types';
6
6
  import type { ConfigPath, ConfigSchema } from '../config/types';
7
7
  import type { ConfigField } from '../controls/types';
8
8
  import type { JobCapability, JobHandle } from '../job/types';
9
- import type { LoupeDeclaration } from '../loupe/types';
10
9
  import type { TrialInfo } from '../state/types';
11
10
  import type { ToolCapability } from '../tools/types';
12
11
 
@@ -182,11 +181,6 @@ export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
182
181
  * is allowed to mean. Declaring it is what makes the trial provide the
183
182
  * annotation overlay and its chrome. */
184
183
  annotations?: AnnotationsCapability<TS, TC>;
185
- /** Magnification. `true` takes every default and re-draws the instrument's
186
- * canvas layers through a zoomed camera; an instrument whose content is DOM
187
- * gives a `render` that draws it again at a camera it is handed. A function
188
- * is re-read as the config changes, so a setting can drive the lens. */
189
- loupe?: LoupeDeclaration<TS, TC> | ((config: TC) => LoupeDeclaration<TS, TC>);
190
184
  /** Chrome this instrument contributes beyond what its capabilities imply. */
191
185
  chrome?: TrialContribution[];
192
186
  /** Work too slow to do during a render. The runtime starts it, aborts it on