@weasel-js/labkit 1.6.1 → 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 (162) hide show
  1. package/dist/_dts/{CanvasStackContext-Wo6OwIVM.d.ts → CanvasStackContext-D2jnIwfk.d.ts} +1 -1
  2. package/dist/_dts/{LayerList.d-BoELAMsM.d.ts → LayerList.d-DiuEArKq.d.ts} +2 -2
  3. package/dist/_dts/PrefsRow.d-TOZ7BgD9.d.ts +79 -0
  4. package/dist/_dts/PropertyPanel.d-D-1paCbE.d.ts +141 -0
  5. package/dist/_dts/{Select.d-Cr8-zoup.d.ts → Select.d-Bensr_MT.d.ts} +11 -1
  6. package/dist/_dts/{Stage-DWBBi8IU.d.ts → Stage-CCWfQQWM.d.ts} +58 -18
  7. package/dist/_dts/index-B2Yj5aA0.d.ts +274 -0
  8. package/dist/_dts/{index-CKyX0ME_.d.ts → index-CsYqUHD3.d.ts} +2 -2
  9. package/dist/_dts/{types-DTuViBvc.d.ts → types-CRPSGFAp.d.ts} +13 -4
  10. package/dist/_dts/{types-B7yBTTF9.d.ts → types-CSm9jgol.d.ts} +2 -2
  11. package/dist/_dts/{useTrialState-CMaV6UD4.d.ts → useTrialState-CTZPgeZ7.d.ts} +1 -1
  12. package/dist/canvas/index.d.ts +6 -6
  13. package/dist/canvas/index.js +2 -2
  14. package/dist/chrome/index.d.ts +5 -5
  15. package/dist/chrome/index.js +8 -8
  16. package/dist/{chunk-QGMOA2YE.js → chunk-275JRRVD.js} +67 -70
  17. package/dist/chunk-275JRRVD.js.map +1 -0
  18. package/dist/{chunk-KHK5FGER.js → chunk-4LJSC7MB.js} +8 -10
  19. package/dist/chunk-4LJSC7MB.js.map +1 -0
  20. package/dist/{chunk-SLAQPUI4.js → chunk-7BO7HNGF.js} +5 -5
  21. package/dist/{chunk-SLAQPUI4.js.map → chunk-7BO7HNGF.js.map} +1 -1
  22. package/dist/chunk-CZZPXUOU.js +3 -0
  23. package/dist/{chunk-SBXNQW4G.js.map → chunk-CZZPXUOU.js.map} +1 -1
  24. package/dist/{chunk-6FLXL3I7.js → chunk-FMK7UQ6V.js} +19 -4
  25. package/dist/chunk-FMK7UQ6V.js.map +1 -0
  26. package/dist/{chunk-O6FFFOHS.js → chunk-FZ7YFQN7.js} +16 -8
  27. package/dist/chunk-FZ7YFQN7.js.map +1 -0
  28. package/dist/{chunk-GHGO52SO.js → chunk-GOZLNM4L.js} +5 -5
  29. package/dist/{chunk-GHGO52SO.js.map → chunk-GOZLNM4L.js.map} +1 -1
  30. package/dist/{chunk-4TT3PNPR.js → chunk-I5DUQ5HM.js} +33 -36
  31. package/dist/chunk-I5DUQ5HM.js.map +1 -0
  32. package/dist/{chunk-NEXR37YJ.js → chunk-J7OWWTCW.js} +4 -5
  33. package/dist/chunk-J7OWWTCW.js.map +1 -0
  34. package/dist/{chunk-IRTGUQ5O.js → chunk-KMJ7RWD3.js} +16 -6
  35. package/dist/chunk-KMJ7RWD3.js.map +1 -0
  36. package/dist/{chunk-S4NCI32V.js → chunk-M7HP3FTK.js} +4 -4
  37. package/dist/{chunk-S4NCI32V.js.map → chunk-M7HP3FTK.js.map} +1 -1
  38. package/dist/chunk-MIM5R4ZT.js +14972 -0
  39. package/dist/chunk-MIM5R4ZT.js.map +1 -0
  40. package/dist/{chunk-ASOK76ZH.js → chunk-T5IBSPAM.js} +59 -26
  41. package/dist/chunk-T5IBSPAM.js.map +1 -0
  42. package/dist/{chunk-DWMSB4CA.js → chunk-UNAZSLWE.js} +82 -16
  43. package/dist/chunk-UNAZSLWE.js.map +1 -0
  44. package/dist/{chunk-TJ77A4A3.js → chunk-Y23G2VZL.js} +5 -9
  45. package/dist/chunk-Y23G2VZL.js.map +1 -0
  46. package/dist/{chunk-HKV7LRHY.js → chunk-YT75VYRD.js} +4 -4
  47. package/dist/{chunk-HKV7LRHY.js.map → chunk-YT75VYRD.js.map} +1 -1
  48. package/dist/{chunk-CGHF6RZV.js → chunk-ZIFPHIWZ.js} +9 -7
  49. package/dist/chunk-ZIFPHIWZ.js.map +1 -0
  50. package/dist/config/index.d.ts +16 -9
  51. package/dist/config/index.js +4 -4
  52. package/dist/controls/index.d.ts +2 -2
  53. package/dist/controls/index.js +5 -5
  54. package/dist/dragdrop/index.d.ts +3 -3
  55. package/dist/index.d.ts +263 -24
  56. package/dist/index.js +376 -237
  57. package/dist/index.js.map +1 -1
  58. package/dist/layers/index.d.ts +4 -4
  59. package/dist/layers/index.js +2 -2
  60. package/dist/loupe/index.d.ts +4 -4
  61. package/dist/loupe/index.js +2 -2
  62. package/dist/overview/index.d.ts +2 -2
  63. package/dist/overview/index.js +9 -8
  64. package/dist/overview/index.js.map +1 -1
  65. package/dist/passthrough/weasel-ui.d.ts +208 -38
  66. package/dist/passthrough/weasel-ui.js +3 -3
  67. package/dist/primitives/index.d.ts +3 -178
  68. package/dist/primitives/index.js +6 -6
  69. package/dist/state/index.d.ts +5 -5
  70. package/dist/state/index.js +4 -4
  71. package/dist/styles.css +91 -83
  72. package/dist/undo/index.d.ts +3 -3
  73. package/package.json +13 -12
  74. package/src/annotations/AnnotationOverlay.tsx +2 -2
  75. package/src/annotations/Annotations.less +0 -10
  76. package/src/annotations/MarkList.tsx +4 -8
  77. package/src/annotations/paint.test.ts +3 -14
  78. package/src/annotations/paint.ts +3 -19
  79. package/src/annotations/svgNodes.test.ts +6 -9
  80. package/src/annotations/svgNodes.ts +3 -3
  81. package/src/canvas/AGENTS.md +1 -0
  82. package/src/canvas/CameraInput.tsx +44 -33
  83. package/src/canvas/CanvasStack.tsx +6 -1
  84. package/src/canvas/LinkedCursor.less +1 -1
  85. package/src/canvas/Stage.test.tsx +33 -4
  86. package/src/canvas/Stage.tsx +6 -1
  87. package/src/canvas/cameraRegistry.test.ts +55 -0
  88. package/src/canvas/cameraRegistry.ts +86 -0
  89. package/src/canvas/cameraZoom.test.ts +46 -0
  90. package/src/canvas/cameraZoom.ts +24 -0
  91. package/src/canvas/index.ts +14 -2
  92. package/src/canvas/sharedScope.test.tsx +94 -0
  93. package/src/chrome/ChromeRegions.stories.tsx +1 -0
  94. package/src/chrome/regions/PaletteRegion.less +0 -7
  95. package/src/chrome/regions/SidebarRegion.less +12 -2
  96. package/src/config/builder.test.ts +2 -1
  97. package/src/config/builder.ts +12 -5
  98. package/src/config/fromConfigField.test.ts +14 -0
  99. package/src/config/fromConfigField.ts +18 -4
  100. package/src/config/path.ts +2 -5
  101. package/src/config/resolve.ts +4 -0
  102. package/src/config/types.ts +13 -9
  103. package/src/controls/ControlPanel.less +1 -1
  104. package/src/controls/ControlPanel.stories.tsx +3 -2
  105. package/src/controls/ControlPanel.test.tsx +64 -11
  106. package/src/controls/ControlPanel.tsx +52 -2
  107. package/src/index.ts +15 -1
  108. package/src/lab/Lab.tsx +83 -72
  109. package/src/lab/{LabFit.stories.less → LabFit.browser.test.less} +1 -1
  110. package/src/lab/{LabFit.stories.tsx → LabFit.browser.test.tsx} +41 -63
  111. package/src/lab/LabFullChrome.stories.tsx +1 -0
  112. package/src/lab/LabHeader.test.tsx +24 -14
  113. package/src/lab/LabHeader.tsx +11 -12
  114. package/src/lab/LabShell.less +8 -17
  115. package/src/lab/LabSwitcher.less +6 -6
  116. package/src/lab/LabZoom.test.tsx +223 -0
  117. package/src/lab/LabZoom.tsx +170 -0
  118. package/src/lab/index.ts +1 -0
  119. package/src/loupe/LoupeGestures.tsx +1 -1
  120. package/src/overview/TrialOverview.tsx +7 -5
  121. package/src/passthrough/weasel-ui.test.ts +2 -0
  122. package/src/passthrough/weasel-ui.ts +16 -4
  123. package/src/primitives/FloatingPanel.test.tsx +11 -0
  124. package/src/primitives/FloatingPanel.tsx +2 -5
  125. package/src/primitives/JobProgress.less +1 -1
  126. package/src/primitives/Legend.stories.tsx +1 -0
  127. package/src/primitives/Readout.browser.test.tsx +137 -0
  128. package/src/primitives/Readout.less +12 -0
  129. package/src/primitives/Readout.stories.tsx +58 -0
  130. package/src/primitives/Readout.test.tsx +93 -0
  131. package/src/primitives/Readout.tsx +56 -0
  132. package/src/primitives/index.ts +2 -0
  133. package/src/specimen/Specimen.stories.tsx +1 -0
  134. package/src/specimen/Specimen.tsx +2 -2
  135. package/src/state/Persistence.browser.test.tsx +47 -0
  136. package/src/state/store.test.ts +5 -1
  137. package/src/state/usePersistedState.test.tsx +1 -1
  138. package/src/styles.less +1 -0
  139. package/src/theme/Interstellar.stories.tsx +3 -1
  140. package/src/theme/base.less +21 -47
  141. package/src/theme/interstellar.test.ts +1 -1
  142. package/src/theme/interstellar.theme.json +10 -3
  143. package/src/trial/Trial.less +0 -13
  144. package/src/trial/Trial.tsx +12 -6
  145. package/src/trial/TrialChrome.tsx +21 -3
  146. package/dist/_dts/PrefsRow.d-DFpPIeIC.d.ts +0 -39
  147. package/dist/_dts/PropertyField.d-D0ndfIIt.d.ts +0 -404
  148. package/dist/chunk-4TT3PNPR.js.map +0 -1
  149. package/dist/chunk-6FLXL3I7.js.map +0 -1
  150. package/dist/chunk-ASOK76ZH.js.map +0 -1
  151. package/dist/chunk-CGHF6RZV.js.map +0 -1
  152. package/dist/chunk-DWMSB4CA.js.map +0 -1
  153. package/dist/chunk-IRTGUQ5O.js.map +0 -1
  154. package/dist/chunk-KHK5FGER.js.map +0 -1
  155. package/dist/chunk-NEXR37YJ.js.map +0 -1
  156. package/dist/chunk-O6FFFOHS.js.map +0 -1
  157. package/dist/chunk-QGMOA2YE.js.map +0 -1
  158. package/dist/chunk-SBXNQW4G.js +0 -3
  159. package/dist/chunk-TJ77A4A3.js.map +0 -1
  160. package/dist/chunk-TYN6MLXX.js +0 -13864
  161. package/dist/chunk-TYN6MLXX.js.map +0 -1
  162. package/src/state/Persistence.stories.tsx +0 -75
@@ -58,28 +58,38 @@ describe('<LabHeader>', () => {
58
58
  <Lab instruments={[Stub, Other]} defaultInstrument="Stub" addTrial={false} />,
59
59
  );
60
60
  expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
61
- expect(screen.getByRole('radio', { name: 'Auto' })).toBeInTheDocument();
61
+ expect(screen.getByRole('button', { name: /^Theme: Auto/ })).toBeInTheDocument();
62
62
  unmount();
63
63
  render(<Lab instruments={[Stub]} defaultInstrument="Stub" addTrial={false} />);
64
64
  expect(screen.queryByRole('button', { name: /add trial/i })).toBeNull();
65
65
  });
66
66
 
67
- it('exposes the color mode as a three-way choice', () => {
67
+ it('rotates the color mode from one button', async () => {
68
+ const { default: userEvent } = await import('@testing-library/user-event');
69
+ const user = userEvent.setup();
68
70
  render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
69
- for (const label of ['Auto', 'Light', 'Dark']) {
70
- expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
71
+ expect(screen.queryByRole('radiogroup')).toBeNull();
72
+ for (const [now, next] of [
73
+ ['Auto', 'Light'],
74
+ ['Light', 'Dark'],
75
+ ['Dark', 'Auto'],
76
+ ]) {
77
+ const button = screen.getByRole('button', { name: `Theme: ${now} — click for ${next}` });
78
+ expect(button.querySelector('svg')).not.toBeNull();
79
+ expect(button).toHaveTextContent('');
80
+ await user.click(button);
71
81
  }
82
+ expect(screen.getByRole('button', { name: /^Theme: Auto/ })).toBeInTheDocument();
72
83
  });
73
84
 
74
- it('draws each color mode as a glyph, with the word only as its name', () => {
75
- // The assertion above passes whether the segment holds text or an icon,
76
- // because both routes produce the same accessible name. This is the one
77
- // that can tell them apart.
78
- render(<Lab instruments={[Stub]} defaultInstrument="Stub" />);
79
- for (const label of ['Auto', 'Light', 'Dark']) {
80
- const segment = screen.getByRole('radio', { name: label });
81
- expect(segment.querySelector('svg')).not.toBeNull();
82
- expect(segment).toHaveTextContent('');
83
- }
85
+ it('puts the theme switch after the header content a consumer passes', () => {
86
+ render(
87
+ <Lab instruments={[Stub]} defaultInstrument="Stub">
88
+ <button type="button">Consumer</button>
89
+ </Lab>,
90
+ );
91
+ const consumer = screen.getByRole('button', { name: 'Consumer' });
92
+ const theme = screen.getByRole('button', { name: /^Theme: / });
93
+ expect(consumer.compareDocumentPosition(theme) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
84
94
  });
85
95
  });
@@ -1,11 +1,10 @@
1
1
  import { AddIcon } from '@weasel-js/ui';
2
- import { ColorModeControl, MenuButton } from '../passthrough/weasel-ui';
2
+ import { MenuButton, ThemeSwitcher } from '../passthrough/weasel-ui';
3
3
  import { useLabContext } from './LabContext';
4
4
 
5
- /** The controls `<Lab>` puts in its header: add a trial, and choose the color
6
- * mode. Both drive `LabContext`, which carried them with no UI at all — so
7
- * every consumer rebuilt these two. Rendered before a consumer's own header
8
- * content, which still lands beside them. */
5
+ /** The control `<Lab>` puts at the start of its header: add a trial. It drives
6
+ * `LabContext`, which carried it with no UI at all — so every consumer rebuilt
7
+ * it. Rendered before a consumer's own header content. */
9
8
  export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
10
9
  const lab = useLabContext();
11
10
  const only = lab.instruments.length === 1 ? lab.instruments[0] : null;
@@ -29,13 +28,13 @@ export function LabHeader({ addTrial = true }: { addTrial?: boolean }) {
29
28
  onAction={(name) => lab.addTrial(name)}
30
29
  />
31
30
  )}
32
-
33
- <ColorModeControl
34
- className="lk-lab-header__mode"
35
- variant="flat"
36
- value={lab.mode}
37
- onChange={lab.setMode}
38
- />
39
31
  </>
40
32
  );
41
33
  }
34
+
35
+ /** The lab's color-mode switch, which `<Lab>` puts at the end of its header,
36
+ * after a consumer's own header content. */
37
+ export function LabThemeSwitcher() {
38
+ const lab = useLabContext();
39
+ return <ThemeSwitcher value={lab.mode} onChange={lab.setMode} />;
40
+ }
@@ -155,21 +155,12 @@
155
155
  align-self: center;
156
156
  }
157
157
 
158
- // `Select` is a form field and defaults to `width: 100%`. Left to resolve that
159
- // against a shrink-to-fit header row it swallows the slack, which pushed the
160
- // consumer's own header content down to min-content and wrapped the row to
161
- // three lines. See the `.lk-root` prefix convention in theme/base.less.
162
- // The mode toggle is a preference, not a primary action: it sits quiet until
163
- // the pointer is on it.
164
- .lk-lab-header__mode {
165
- opacity: 0.75;
166
- // A consumer's own header content shares this row and does not shrink, so
167
- // without this the segments compress past their labels and the pill track
168
- // disappears entirely.
169
- flex-shrink: 0;
170
-
171
- &:hover,
172
- &:focus-within {
173
- opacity: 1;
174
- }
158
+ // The readout's digit count changes as the zoom does (80% to 125%), so it sits
159
+ // in a cell sized for the widest value the default range reaches, and the
160
+ // buttons either side of it stay put.
161
+ .lk-lab-zoom__readout {
162
+ display: inline-block;
163
+ min-width: 5ch;
164
+ text-align: center;
165
+ font-variant-numeric: tabular-nums;
175
166
  }
@@ -29,9 +29,9 @@
29
29
  &:hover { color: var(--wzl-accent-fg); }
30
30
 
31
31
  &:focus-visible {
32
- outline: 2px solid var(--wzl-focus-ring, currentColor);
32
+ outline: 2px solid var(--wzl-focus-ring);
33
33
  outline-offset: 2px;
34
- border-radius: var(--wzl-radius-sm, 3px);
34
+ border-radius: var(--wzl-radius-sm);
35
35
  }
36
36
  }
37
37
 
@@ -64,22 +64,22 @@
64
64
  // surface, the way `FloatingPanel` does.
65
65
  background: var(--wzl-surface);
66
66
  border: 1px solid var(--wzl-border);
67
- border-radius: var(--wzl-radius-md, 5px);
67
+ border-radius: var(--wzl-radius-md);
68
68
  box-shadow: 0 6px 20px var(--wzl-shadow);
69
69
  }
70
70
 
71
71
  .lk-switcher-item {
72
72
  padding: var(--wzl-space-xs) var(--wzl-space-sm);
73
- border-radius: var(--wzl-radius-sm, 3px);
73
+ border-radius: var(--wzl-radius-sm);
74
74
  color: var(--wzl-fg);
75
75
  font-size: var(--wzl-font-size);
76
76
  text-decoration: none;
77
77
  white-space: nowrap;
78
78
 
79
- &:hover { background: var(--wzl-surface-hover, var(--wzl-surface)); }
79
+ &:hover { background: var(--wzl-surface-hover); }
80
80
 
81
81
  &:focus-visible {
82
- outline: 2px solid var(--wzl-focus-ring, currentColor);
82
+ outline: 2px solid var(--wzl-focus-ring);
83
83
  outline-offset: -2px;
84
84
  }
85
85
  }
@@ -0,0 +1,223 @@
1
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
+ import {
3
+ ActionsProvider,
4
+ type ActionsRegistry,
5
+ ActiveToolContextProvider,
6
+ DepRegistryProvider,
7
+ type Tool,
8
+ useActionsRegistry,
9
+ useGestureDispatcher,
10
+ } from '@weasel-js/core';
11
+ import { type ReactNode, useEffect } from 'react';
12
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
13
+ import { defineInstrument } from '../instrument/defineInstrument';
14
+ import { Lab } from './Lab';
15
+ import { LabContext, type LabContextValue } from './LabContext';
16
+
17
+ beforeAll(() => {
18
+ HTMLCanvasElement.prototype.getContext = vi.fn(
19
+ () => null,
20
+ ) as unknown as HTMLCanvasElement['getContext'];
21
+ });
22
+
23
+ const staged = defineInstrument<Record<string, never>, Record<string, never>>({
24
+ name: 'Staged',
25
+ defaultConfig: () => ({}),
26
+ initialState: () => ({}),
27
+ render: () => <div data-testid="art" />,
28
+ stage: {
29
+ size: { width: 240, height: 160 },
30
+ initialView: { zoom: 1, pan: { x: 0, y: 0 } },
31
+ maxZoom: 1.5,
32
+ },
33
+ });
34
+
35
+ const plain = defineInstrument<Record<string, never>, Record<string, never>>({
36
+ name: 'Plain',
37
+ defaultConfig: () => ({}),
38
+ initialState: () => ({}),
39
+ render: () => <div data-testid="plain" />,
40
+ });
41
+
42
+ let lab: LabContextValue | null = null;
43
+ function Capture({ children }: { children?: ReactNode }) {
44
+ return (
45
+ <LabContext.Consumer>
46
+ {(value) => {
47
+ lab = value;
48
+ return children ?? null;
49
+ }}
50
+ </LabContext.Consumer>
51
+ );
52
+ }
53
+
54
+ const zoomBar = () => within(screen.getByRole('toolbar', { name: 'Zoom' }));
55
+ const readout = () => zoomBar().getByRole('button', { name: /^Zoom \d+%|^Zoom unavailable/ });
56
+ const press = (name: string) =>
57
+ act(() => {
58
+ fireEvent.click(zoomBar().getByRole('button', { name }));
59
+ });
60
+
61
+ const MOD = /mac/i.test(navigator.platform || navigator.userAgent)
62
+ ? { metaKey: true }
63
+ : { ctrlKey: true };
64
+ function key(k: string): boolean {
65
+ let prevented = false;
66
+ act(() => {
67
+ prevented = !fireEvent.keyDown(window, { key: k, ...MOD });
68
+ });
69
+ return prevented;
70
+ }
71
+
72
+ const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
73
+
74
+ /** A story's own dispatcher, binding Mod+= the way a `<SceneCanvas>` with keyboard zoom does. */
75
+ function StoryZoomKey({ run }: { run: () => void }) {
76
+ function Bound() {
77
+ const registry = useActionsRegistry() as ActionsRegistry;
78
+ useEffect(
79
+ () =>
80
+ registry.register({
81
+ id: 'story.zoom',
82
+ label: 'Story zoom',
83
+ defaultBinding: { kind: 'key', key: '=', mods: { mod: true } },
84
+ invoker: { timing: 'immediate', run: () => run() },
85
+ }),
86
+ [registry],
87
+ );
88
+ useGestureDispatcher({
89
+ canvasRef: { current: null },
90
+ actions: registry,
91
+ entriesById: NO_TOOLS,
92
+ channels: { contextMenu: false, ingest: false },
93
+ });
94
+ return null;
95
+ }
96
+ return (
97
+ <DepRegistryProvider>
98
+ <ActionsProvider>
99
+ <ActiveToolContextProvider>
100
+ <Bound />
101
+ </ActiveToolContextProvider>
102
+ </ActionsProvider>
103
+ </DepRegistryProvider>
104
+ );
105
+ }
106
+
107
+ function stageZoom(container: HTMLElement, index = 0): number {
108
+ const el = container.querySelectorAll<HTMLElement>('.lk-stage__content')[index];
109
+ if (!el) throw new Error('no stage content');
110
+ return Number(el.style.getPropertyValue('--lk-stage-zoom'));
111
+ }
112
+
113
+ describe("the lab header's zoom controls", () => {
114
+ it('step the focused trial through its camera and read its zoom back', () => {
115
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
116
+ expect(readout()).toHaveTextContent('100%');
117
+ press('Zoom in');
118
+ expect(stageZoom(container)).toBeCloseTo(1.25);
119
+ expect(readout()).toHaveTextContent('125%');
120
+ press('Zoom out');
121
+ expect(stageZoom(container)).toBeCloseTo(1);
122
+ press('Zoom out');
123
+ expect(readout()).toHaveTextContent('80%');
124
+ });
125
+
126
+ it('reset to actual size from the readout', () => {
127
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
128
+ press('Zoom out');
129
+ press('Zoom out');
130
+ act(() => {
131
+ fireEvent.click(readout());
132
+ });
133
+ expect(stageZoom(container)).toBe(1);
134
+ expect(readout()).toHaveTextContent('100%');
135
+ });
136
+
137
+ it("stop at the camera's own limit, and say so", () => {
138
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
139
+ press('Zoom in');
140
+ press('Zoom in');
141
+ expect(stageZoom(container)).toBe(1.5);
142
+ expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
143
+ expect(zoomBar().getByRole('button', { name: 'Zoom out' })).toBeEnabled();
144
+ });
145
+
146
+ it('answer Mod+=, Mod+- and Mod+0, claiming the key from the browser', () => {
147
+ const { container } = render(<Lab instruments={[staged]} defaultInstrument="Staged" />);
148
+ expect(key('=')).toBe(true);
149
+ expect(stageZoom(container)).toBeCloseTo(1.25);
150
+ key('-');
151
+ key('-');
152
+ expect(stageZoom(container)).toBeCloseTo(0.8);
153
+ key('0');
154
+ expect(stageZoom(container)).toBe(1);
155
+ });
156
+
157
+ it('go inert over a trial with no camera, and leave its keys to the browser', () => {
158
+ render(<Lab instruments={[plain]} defaultInstrument="Plain" />);
159
+ for (const name of ['Zoom out', 'Zoom in']) {
160
+ expect(zoomBar().getByRole('button', { name })).toBeDisabled();
161
+ }
162
+ expect(readout()).toBeDisabled();
163
+ expect(readout()).toHaveTextContent('–');
164
+ expect(key('=')).toBe(false);
165
+ });
166
+
167
+ it("take the key ahead of a story's own binding in a trial with a camera", () => {
168
+ const story = vi.fn();
169
+ const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
170
+ ...staged,
171
+ name: 'Zooming',
172
+ render: () => <StoryZoomKey run={story} />,
173
+ });
174
+ const { container } = render(<Lab instruments={[zooming]} defaultInstrument="Zooming" />);
175
+ key('=');
176
+ expect(stageZoom(container)).toBeCloseTo(1.25);
177
+ expect(story).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("leave the key to a story's own binding in a trial with no camera", () => {
181
+ const story = vi.fn();
182
+ const zooming = defineInstrument<Record<string, never>, Record<string, never>>({
183
+ ...plain,
184
+ name: 'ZoomingPlain',
185
+ render: () => <StoryZoomKey run={story} />,
186
+ });
187
+ render(<Lab instruments={[zooming]} defaultInstrument="ZoomingPlain" />);
188
+ key('=');
189
+ expect(story).toHaveBeenCalledTimes(1);
190
+ });
191
+
192
+ it('follow the focus from trial to trial', () => {
193
+ const { container } = render(
194
+ <Lab instruments={[staged, plain]} defaultInstrument="Staged">
195
+ <Capture />
196
+ </Lab>,
197
+ );
198
+ act(() => lab?.addTrial('Staged'));
199
+ act(() => lab?.addTrial('Plain'));
200
+ const [first, second, third] = lab?.trials.map((t) => t.id) ?? [];
201
+ expect(zoomBar().getByRole('button', { name: 'Zoom in' })).toBeDisabled();
202
+
203
+ act(() => lab?.focusTrial(second ?? ''));
204
+ press('Zoom out');
205
+ expect(stageZoom(container, 1)).toBeCloseTo(0.8);
206
+ expect(stageZoom(container, 0)).toBe(1);
207
+ expect(readout()).toHaveTextContent('80%');
208
+
209
+ act(() => lab?.focusTrial(first ?? ''));
210
+ expect(readout()).toHaveTextContent('100%');
211
+ key('=');
212
+ expect(stageZoom(container, 0)).toBeCloseTo(1.25);
213
+ expect(stageZoom(container, 1)).toBeCloseTo(0.8);
214
+
215
+ act(() => lab?.focusTrial(third ?? ''));
216
+ expect(readout()).toBeDisabled();
217
+ });
218
+
219
+ it('are left out when the lab says so', () => {
220
+ render(<Lab instruments={[staged]} defaultInstrument="Staged" zoom={false} />);
221
+ expect(screen.queryByRole('toolbar', { name: 'Zoom' })).toBeNull();
222
+ });
223
+ });
@@ -0,0 +1,170 @@
1
+ import {
2
+ type Action,
3
+ ActionDisabledReason,
4
+ ActionsProvider,
5
+ type ActionsRegistry,
6
+ ActiveToolContextProvider,
7
+ DepRegistryProvider,
8
+ makeViewportZoomAction,
9
+ type Tool,
10
+ useActionsRegistry,
11
+ useDepSource,
12
+ useGestureDispatcher,
13
+ type ViewApi,
14
+ } from '@weasel-js/core';
15
+ import { ZoomInIcon, ZoomOutIcon } from '@weasel-js/ui';
16
+ import { useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
17
+ import { useStore } from 'zustand/react';
18
+ import type { CameraView } from '../canvas/CameraInput';
19
+ import { CameraRegistryContext } from '../canvas/cameraRegistry';
20
+ import { atScale, viewMiddle } from '../canvas/cameraZoom';
21
+ import { formatShortcut } from '../passthrough/weasel-ui';
22
+ import { Toolbar } from '../primitives/Toolbar';
23
+ import { LabStoreContext } from '../state/context';
24
+ import { as2DView } from '../state/view';
25
+ import { useLabContext } from './LabContext';
26
+
27
+ const ZOOM_ID = 'viewport.zoom';
28
+ const NO_TOOLS: ReadonlyMap<string, Tool> = new Map();
29
+ const CHANNELS = { contextMenu: false, ingest: false } as const;
30
+ const NO_ELEMENT = { current: null };
31
+
32
+ type ZoomKind = 'in' | 'out' | 'reset';
33
+
34
+ interface KeyBinding {
35
+ spec: { kind: string; key?: string; mods?: { mod?: boolean; shift?: boolean | 'optional' } };
36
+ opts?: { params?: { kind?: string } };
37
+ }
38
+
39
+ // Core's zoom with only its keys: the wheel and pinch belong to each trial's
40
+ // own dispatcher. The clamp is left to the camera, which knows its range.
41
+ const BASE = makeViewportZoomAction({ min: 1e-9, max: Number.POSITIVE_INFINITY });
42
+ const KEY_BINDINGS = (BASE.defaultBinding as KeyBinding[]).filter((b) => b.spec.kind === 'key');
43
+
44
+ function shortcutFor(kind: ZoomKind): string | undefined {
45
+ const spec = KEY_BINDINGS.find((b) => b.opts?.params?.kind === kind)?.spec;
46
+ return spec?.key ? formatShortcut({ key: spec.key, mod: spec.mods?.mod }) : undefined;
47
+ }
48
+
49
+ /** The focused trial's camera, with the zoom's reset meaning actual size about
50
+ * the middle of the view. */
51
+ function forZoom(camera: CameraView): ViewApi {
52
+ return {
53
+ get: camera.get,
54
+ set: camera.set,
55
+ hostSize: camera.hostSize,
56
+ recenter: () => atScale(camera.get(), 1, viewMiddle(camera)),
57
+ };
58
+ }
59
+
60
+ const percent = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 0 });
61
+
62
+ /**
63
+ * Zoom out, the zoom as a percentage, and zoom in, for whichever trial has the
64
+ * focus. The percentage resets to actual size. They route through core's
65
+ * `viewport.zoom` action, on a dispatcher of their own that listens for its
66
+ * keys — Mod+=, Mod+- and Mod+0 — so the buttons and the keys are one path.
67
+ * Over a trial with no camera the group stays, disabled, so the header does not
68
+ * reflow as the focus moves.
69
+ */
70
+ export function LabZoom() {
71
+ return (
72
+ <DepRegistryProvider>
73
+ <ActionsProvider>
74
+ <ActiveToolContextProvider>
75
+ <LabZoomControls />
76
+ </ActiveToolContextProvider>
77
+ </ActionsProvider>
78
+ </DepRegistryProvider>
79
+ );
80
+ }
81
+
82
+ function LabZoomControls() {
83
+ const registry = useActionsRegistry() as ActionsRegistry;
84
+ const { focusedTrialId } = useLabContext();
85
+ const cameras = useContext(CameraRegistryContext);
86
+ const storeCtx = useContext(LabStoreContext);
87
+ if (!storeCtx) throw new Error('[labkit] <LabZoom> requires <LabStoreProvider>');
88
+
89
+ const camera = useSyncExternalStore(cameras?.subscribe ?? noSubscribe, () =>
90
+ cameras && focusedTrialId ? cameras.get(focusedTrialId) : null,
91
+ );
92
+ const zoom = useStore(storeCtx.store, (s) => {
93
+ const record = s.trials.find((t) => t.id === focusedTrialId);
94
+ return as2DView(record?.view)?.zoom ?? null;
95
+ });
96
+
97
+ const live = zoom !== null && camera !== null;
98
+ const view = useMemo(() => (camera ? forZoom(camera) : null), [camera]);
99
+ const viewRef = useRef(view);
100
+ viewRef.current = live ? view : null;
101
+
102
+ useDepSource('view', () => viewRef.current as ViewApi);
103
+
104
+ useEffect(() => {
105
+ const action: Action = {
106
+ ...BASE,
107
+ defaultBinding: KEY_BINDINGS as Action['defaultBinding'],
108
+ enabled: () => (viewRef.current ? true : ActionDisabledReason.NotApplicable),
109
+ };
110
+ return registry.register(action);
111
+ }, [registry]);
112
+
113
+ // First, so a story's own zoom keys do not also fire over a trial with a
114
+ // camera; over one without, the action is disabled and the keys pass on.
115
+ useGestureDispatcher({
116
+ canvasRef: NO_ELEMENT,
117
+ actions: registry,
118
+ entriesById: NO_TOOLS,
119
+ channels: CHANNELS,
120
+ keyboard: 'first',
121
+ });
122
+
123
+ const range = camera?.zoomRange();
124
+ // A step that lands within rounding of a limit has reached it.
125
+ const canIn = zoom !== null && range !== undefined && zoom < range.max * (1 - 1e-9);
126
+ const canOut = zoom !== null && range !== undefined && zoom > range.min * (1 + 1e-9);
127
+ const run = (kind: ZoomKind) => () => {
128
+ registry.trigger(ZOOM_ID, { kind });
129
+ };
130
+ const hint = (label: string, kind: ZoomKind): string => {
131
+ const keys = shortcutFor(kind);
132
+ return keys ? `${label} (${keys})` : label;
133
+ };
134
+ const shown = live && zoom !== null ? percent.format(zoom) : null;
135
+
136
+ return (
137
+ <Toolbar aria-label="Zoom">
138
+ <Toolbar.Group aria-label="Zoom">
139
+ <Toolbar.Button
140
+ iconOnly
141
+ aria-label="Zoom out"
142
+ title={hint('Zoom out', 'out')}
143
+ disabled={!canOut}
144
+ onClick={run('out')}
145
+ >
146
+ <ZoomOutIcon size={16} />
147
+ </Toolbar.Button>
148
+ <Toolbar.Button
149
+ aria-label={shown ? `Zoom ${shown} — reset to actual size` : 'Zoom unavailable'}
150
+ title={live ? hint('Actual size', 'reset') : 'This trial has no camera'}
151
+ disabled={!live}
152
+ onClick={run('reset')}
153
+ >
154
+ <span className="lk-lab-zoom__readout">{shown ?? '–'}</span>
155
+ </Toolbar.Button>
156
+ <Toolbar.Button
157
+ iconOnly
158
+ aria-label="Zoom in"
159
+ title={hint('Zoom in', 'in')}
160
+ disabled={!canIn}
161
+ onClick={run('in')}
162
+ >
163
+ <ZoomInIcon size={16} />
164
+ </Toolbar.Button>
165
+ </Toolbar.Group>
166
+ </Toolbar>
167
+ );
168
+ }
169
+
170
+ const noSubscribe = (): (() => void) => () => {};
package/src/lab/index.ts CHANGED
@@ -11,5 +11,6 @@ export type { LabShellProps } from './LabShell';
11
11
  export { LabShell } from './LabShell';
12
12
  export type { LabPage, LabSwitcherProps } from './LabSwitcher';
13
13
  export { currentPage, LabSwitcher } from './LabSwitcher';
14
+ export { LabZoom } from './LabZoom';
14
15
  export type { PanelDescriptor, TrialLayout, WorkspaceProps } from './Workspace';
15
16
  export { Workspace } from './Workspace';
@@ -50,7 +50,7 @@ function LoupeDispatch({
50
50
  useGestureDispatcher({
51
51
  canvasRef: hostRef,
52
52
  actions: registry,
53
- toolsById: NO_TOOLS,
53
+ entriesById: NO_TOOLS,
54
54
  channels: CHANNELS,
55
55
  enabled: ownDispatcher,
56
56
  });
@@ -163,11 +163,13 @@ function Overview({
163
163
  }: TrialOverviewProps & { camera: CameraContextValue }) {
164
164
  const size = useMemo(() => ({ width, height }), [width, height]);
165
165
  const content = bounds ? toLocalRect(bounds, camera.frame) : camera.content;
166
- const contentKey = content ? `${content.x},${content.y},${content.width},${content.height}` : '';
167
- // biome-ignore lint/correctness/useExhaustiveDependencies: `content` is rebuilt every render; its numbers, in `contentKey`, are what the fit depends on.
166
+ // `content` is rebuilt every render, so the fit keys on its numbers.
167
+ const framed = content !== null && content !== undefined;
168
+ const { x = 0, y = 0, width: w = 0, height: h = 0 } = content ?? {};
168
169
  const fit = useMemo(
169
- () => (content ? fitRect(content, size) : { x: 0, y: 0, scale: { x: 1, y: 1 } }),
170
- [contentKey, size],
170
+ () =>
171
+ framed ? fitRect({ x, y, width: w, height: h }, size) : { x: 0, y: 0, scale: { x: 1, y: 1 } },
172
+ [framed, x, y, w, h, size],
171
173
  );
172
174
  const boxRef = useRef<HTMLDivElement | null>(null);
173
175
 
@@ -341,7 +343,7 @@ function OverviewDispatch({
341
343
  canvasRef: boxRef,
342
344
  // Always present: `OverviewInput` mounts the provider directly above.
343
345
  actions: registry as NonNullable<typeof registry>,
344
- toolsById: TOOLS_BY_ID,
346
+ entriesById: TOOLS_BY_ID,
345
347
  clientToWorld: clientToLocal,
346
348
  keyboard: false,
347
349
  channels: CHANNELS,
@@ -20,6 +20,7 @@ import {
20
20
  paintGradientTrack,
21
21
  RadioGroup,
22
22
  RangeSlider,
23
+ sampleByInterpolation,
23
24
  Select,
24
25
  Sidebar,
25
26
  Slider,
@@ -38,6 +39,7 @@ describe('weasel-ui passthrough', () => {
38
39
  expect(typeof useReorderDragList).toBe('function');
39
40
  expect(typeof paintGradientTrack).toBe('function');
40
41
  expect(typeof oklchToHex).toBe('function');
42
+ expect(typeof sampleByInterpolation).toBe('function');
41
43
  expect(typeof chromaAt).toBe('function');
42
44
  });
43
45