@weasel-js/labkit 1.4.0-pre.1 → 1.4.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 (120) hide show
  1. package/dist/_dts/{CanvasStackContext-LnCfqNBA.d.ts → CanvasStackContext-BIRUVVGH.d.ts} +1 -1
  2. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  3. package/dist/_dts/{frac-C-2c72Ij.d.ts → frac-X7mWgd7y.d.ts} +10 -4
  4. package/dist/_dts/{index-BDVzvRzQ.d.ts → index-FEa1MudK.d.ts} +8 -2
  5. package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
  6. package/dist/_dts/{types-D6s4b7if.d.ts → types-lg4TSCb2.d.ts} +3 -1
  7. package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
  8. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  9. package/dist/canvas/index.d.ts +16 -16
  10. package/dist/canvas/index.js +2 -2
  11. package/dist/chrome/index.d.ts +6 -6
  12. package/dist/chrome/index.js +5 -5
  13. package/dist/chunk-2YILQ7Y5.js +33 -0
  14. package/dist/chunk-2YILQ7Y5.js.map +1 -0
  15. package/dist/{chunk-TO2FUOKF.js → chunk-53AZ2O2N.js} +16 -13
  16. package/dist/chunk-53AZ2O2N.js.map +1 -0
  17. package/dist/{chunk-QB5SM4BM.js → chunk-7TRFMIWX.js} +55 -41
  18. package/dist/chunk-7TRFMIWX.js.map +1 -0
  19. package/dist/{chunk-6OEYTYML.js → chunk-BMQWLKOL.js} +15 -15
  20. package/dist/chunk-BMQWLKOL.js.map +1 -0
  21. package/dist/{chunk-M75ZU6ZZ.js → chunk-D7QIPGEB.js} +3 -3
  22. package/dist/{chunk-M75ZU6ZZ.js.map → chunk-D7QIPGEB.js.map} +1 -1
  23. package/dist/{chunk-L5QJOOLV.js → chunk-DG2IFMLA.js} +1309 -273
  24. package/dist/chunk-DG2IFMLA.js.map +1 -0
  25. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  26. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  27. package/dist/{chunk-RBGKL7NF.js → chunk-J6SBFZYT.js} +1807 -1814
  28. package/dist/chunk-J6SBFZYT.js.map +1 -0
  29. package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
  30. package/dist/chunk-LJSIUFYD.js.map +1 -0
  31. package/dist/{chunk-AE5CNVRU.js → chunk-MIT3ISW4.js} +37 -31
  32. package/dist/chunk-MIT3ISW4.js.map +1 -0
  33. package/dist/{chunk-HHGVISVZ.js → chunk-MSR7EVS6.js} +7 -5
  34. package/dist/chunk-MSR7EVS6.js.map +1 -0
  35. package/dist/{chunk-ESIQNQ6K.js → chunk-OFCBEZMU.js} +12 -6
  36. package/dist/chunk-OFCBEZMU.js.map +1 -0
  37. package/dist/{chunk-HMFODCOX.js → chunk-PP7ZWAMV.js} +83 -75
  38. package/dist/chunk-PP7ZWAMV.js.map +1 -0
  39. package/dist/{chunk-E2UQVZ44.js → chunk-RLOXQZW3.js} +3 -3
  40. package/dist/{chunk-E2UQVZ44.js.map → chunk-RLOXQZW3.js.map} +1 -1
  41. package/dist/controls/index.d.ts +4 -3
  42. package/dist/controls/index.js +3 -3
  43. package/dist/dragdrop/index.d.ts +5 -5
  44. package/dist/dragdrop/index.js +2 -2
  45. package/dist/index.d.ts +57 -77
  46. package/dist/index.js +308 -161
  47. package/dist/index.js.map +1 -1
  48. package/dist/job/index.js +1 -1
  49. package/dist/layers/index.d.ts +6 -6
  50. package/dist/layers/index.js +3 -3
  51. package/dist/loupe/index.d.ts +7 -13
  52. package/dist/loupe/index.js +2 -2
  53. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  54. package/dist/passthrough/weasel-canvas.js +1 -1
  55. package/dist/passthrough/weasel-ui.d.ts +141 -2866
  56. package/dist/passthrough/weasel-ui.js +2 -2
  57. package/dist/primitives/index.js +4 -4
  58. package/dist/state/index.d.ts +3 -3
  59. package/dist/state/index.js +4 -2
  60. package/dist/state/index.js.map +1 -1
  61. package/dist/styles.css +74 -5
  62. package/dist/surface/index.d.ts +17 -2
  63. package/dist/surface/index.js +3 -2
  64. package/dist/ui/layers/index.js +2 -2
  65. package/dist/undo/index.d.ts +6 -5
  66. package/package.json +8 -8
  67. package/src/annotations/AnnotationOverlay.tsx +10 -6
  68. package/src/annotations/Annotations.less +6 -0
  69. package/src/annotations/Annotations.overlay.test.tsx +113 -0
  70. package/src/annotations/MarkList.tsx +3 -2
  71. package/src/canvas/CanvasStack.tsx +8 -13
  72. package/src/canvas/useOrbit.test.ts +93 -1
  73. package/src/canvas/useOrbit.ts +41 -34
  74. package/src/canvas/usePanZoom.test.ts +102 -1
  75. package/src/canvas/usePanZoom.ts +54 -36
  76. package/src/config/builder.test.ts +5 -0
  77. package/src/config/builder.ts +6 -0
  78. package/src/config/types.ts +1 -0
  79. package/src/controls/ControlPanel.test.tsx +23 -0
  80. package/src/controls/ControlPanel.tsx +3 -0
  81. package/src/job/useJob.ts +1 -1
  82. package/src/lab/Lab.surface.test.tsx +8 -3
  83. package/src/lab/LabHeader.test.tsx +12 -0
  84. package/src/lab/LabHeader.tsx +16 -6
  85. package/src/lab/Workspace.tsx +1 -1
  86. package/src/layers/LayerList.test.tsx +72 -1
  87. package/src/layers/LayerList.tsx +40 -32
  88. package/src/passthrough/weasel-ui.test.ts +9 -0
  89. package/src/passthrough/weasel-ui.ts +4 -0
  90. package/src/primitives/FloatingPanel.test.tsx +82 -0
  91. package/src/primitives/FloatingPanel.tsx +59 -41
  92. package/src/primitives/ZoomControl.tsx +2 -0
  93. package/src/state/store.ts +10 -0
  94. package/src/state/types.ts +3 -0
  95. package/src/surface/AGENTS.md +11 -1
  96. package/src/surface/index.ts +7 -1
  97. package/src/surface/useSurfaceTile.ts +20 -2
  98. package/src/surface/useTiledSurface.ts +4 -1
  99. package/src/theme/Interstellar.stories.tsx +4 -3
  100. package/src/theme/base.less +39 -1
  101. package/src/trial/Trial.less +27 -5
  102. package/src/trial/TrialBody.test.tsx +67 -0
  103. package/src/trial/TrialBody.tsx +155 -0
  104. package/src/trial/TrialChrome.tsx +18 -10
  105. package/src/trial/index.ts +2 -0
  106. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  107. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  108. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  109. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  110. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  111. package/dist/chunk-6OEYTYML.js.map +0 -1
  112. package/dist/chunk-AE5CNVRU.js.map +0 -1
  113. package/dist/chunk-ESIQNQ6K.js.map +0 -1
  114. package/dist/chunk-HHGVISVZ.js.map +0 -1
  115. package/dist/chunk-HMFODCOX.js.map +0 -1
  116. package/dist/chunk-L5QJOOLV.js.map +0 -1
  117. package/dist/chunk-QB5SM4BM.js.map +0 -1
  118. package/dist/chunk-RBGKL7NF.js.map +0 -1
  119. package/dist/chunk-TO2FUOKF.js.map +0 -1
  120. package/dist/chunk-XJ6N32QP.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
- import type { WheelEvent } from 'react';
2
+ import type { PointerEvent as ReactPointerEvent, WheelEvent } from 'react';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import type { ViewTransform } from '../instrument/types';
5
5
  import { screenToWorld } from './canvasCoords';
@@ -109,3 +109,104 @@ describe('usePanZoom', () => {
109
109
  });
110
110
  });
111
111
  });
112
+
113
+ /** The fields `usePanZoom` reads off a React pointerdown, plus the element the
114
+ * session hangs its document listeners off. */
115
+ function press(
116
+ el: HTMLElement,
117
+ x: number,
118
+ y: number,
119
+ init: { pointerId?: number; button?: number; buttons?: number } = {},
120
+ ): ReactPointerEvent<HTMLElement> {
121
+ return {
122
+ pointerId: init.pointerId ?? 1,
123
+ button: init.button ?? 0,
124
+ buttons: init.buttons ?? 1,
125
+ clientX: x,
126
+ clientY: y,
127
+ currentTarget: el,
128
+ } as unknown as ReactPointerEvent<HTMLElement>;
129
+ }
130
+
131
+ function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
132
+ act(() => {
133
+ target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
134
+ });
135
+ }
136
+
137
+ function dragSetup(options?: Partial<UsePanZoomOptions>) {
138
+ let view: ViewTransform = { zoom: 1, pan: { x: 0, y: 0 } };
139
+ const onViewChange = (v: ViewTransform) => {
140
+ view = v;
141
+ };
142
+ const { result, unmount } = renderHook(() =>
143
+ usePanZoom({ ...options, view, onViewChange } as UsePanZoomOptions),
144
+ );
145
+ const el = document.createElement('div');
146
+ document.body.appendChild(el);
147
+ return { result, unmount, el, getView: () => view };
148
+ }
149
+
150
+ describe('usePanZoom dragging', () => {
151
+ it('pans from a move the element never sees', () => {
152
+ const { result, el, getView } = dragSetup();
153
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
154
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
155
+ expect(getView().pan).toEqual({ x: 50, y: 20 });
156
+ });
157
+
158
+ it('ends the drag on a release dispatched off the element', () => {
159
+ const { result, el, getView } = dragSetup();
160
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
161
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
162
+ dispatchOn(document, 'pointerup', { clientX: 150, clientY: 120 });
163
+ expect(result.current.isDragging()).toBe(false);
164
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
165
+ expect(getView().pan).toEqual({ x: 50, y: 20 });
166
+ });
167
+
168
+ it('ends the drag when capture is lost mid-gesture', () => {
169
+ const { result, el, getView } = dragSetup();
170
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
171
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
172
+ dispatchOn(el, 'lostpointercapture', {});
173
+ expect(result.current.isDragging()).toBe(false);
174
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
175
+ expect(getView().pan).toEqual({ x: 50, y: 20 });
176
+ });
177
+
178
+ it('reads a move with nothing held as the release that never arrived', () => {
179
+ const { result, el, getView } = dragSetup();
180
+ act(() => result.current.onPointerDown(press(el, 100, 100, { buttons: 1 })));
181
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
182
+ expect(getView().pan).toEqual({ x: 50, y: 20 });
183
+ dispatchOn(document, 'pointermove', { buttons: 0, clientX: 300, clientY: 300 });
184
+ expect(getView().pan).toEqual({ x: 50, y: 20 });
185
+ expect(result.current.isDragging()).toBe(false);
186
+ });
187
+
188
+ it('reports a release that never crossed the threshold as a tap', () => {
189
+ const taps: number[] = [];
190
+ const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
191
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
192
+ dispatchOn(document, 'pointerup', { clientX: 101, clientY: 100 });
193
+ expect(taps).toEqual([101]);
194
+ });
195
+
196
+ it('does not report a tap when the pointer panned', () => {
197
+ const taps: number[] = [];
198
+ const { result, el } = dragSetup({ onTap: (e) => taps.push(e.clientX) });
199
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
200
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 150, clientY: 120 });
201
+ dispatchOn(document, 'pointerup', { clientX: 150, clientY: 120 });
202
+ expect(taps).toEqual([]);
203
+ });
204
+
205
+ it('drops its listeners when the hook unmounts mid-drag', () => {
206
+ const { result, el, unmount, getView } = dragSetup();
207
+ act(() => result.current.onPointerDown(press(el, 100, 100)));
208
+ unmount();
209
+ dispatchOn(document, 'pointermove', { buttons: 1, clientX: 400, clientY: 400 });
210
+ expect(getView().pan).toEqual({ x: 0, y: 0 });
211
+ });
212
+ });
@@ -1,4 +1,11 @@
1
- import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';
1
+ import { openPointerSession, type PointerSession } from '@weasel-js/core';
2
+ import {
3
+ type PointerEvent as ReactPointerEvent,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ type WheelEvent,
8
+ } from 'react';
2
9
  import type { ViewTransform } from '../instrument/types';
3
10
  import { zoomAt } from './camera';
4
11
  import { DEFAULT_FRAME, type WorldFrame } from './worldSpec';
@@ -14,18 +21,19 @@ export interface UsePanZoomOptions {
14
21
  * wheel anchors in this frame; omitting it anchors at the element's
15
22
  * top-left, which is what labkit did before a frame could be declared. */
16
23
  frame?: WorldFrame;
24
+ /** A primary-button press released without ever crossing the drag
25
+ * threshold. The pointer session owns the release, so the gesture's end is
26
+ * reported here rather than left for a consumer's own `onPointerUp`. */
27
+ onTap?: (e: PointerEvent) => void;
17
28
  }
18
29
 
19
30
  export interface PanZoomHandlers {
20
31
  onWheel: (e: WheelEvent<HTMLElement>) => void;
21
- onPointerDown: (e: PointerEvent<HTMLElement>) => void;
22
- onPointerMove: (e: PointerEvent<HTMLElement>) => void;
23
- onPointerUp: (e: PointerEvent<HTMLElement>) => void;
32
+ onPointerDown: (e: ReactPointerEvent<HTMLElement>) => void;
24
33
  isDragging: () => boolean;
25
34
  }
26
35
 
27
36
  interface DragState {
28
- pointerId: number;
29
37
  startScreenX: number;
30
38
  startScreenY: number;
31
39
  startPan: { x: number; y: number };
@@ -44,10 +52,16 @@ export function usePanZoom({
44
52
  minZoom = 0.1,
45
53
  maxZoom = 32,
46
54
  frame = DEFAULT_FRAME,
55
+ onTap,
47
56
  }: UsePanZoomOptions): PanZoomHandlers {
48
57
  const dragRef = useRef<DragState | null>(null);
58
+ const sessionRef = useRef<PointerSession | null>(null);
49
59
  const viewRef = useRef(view);
50
60
  viewRef.current = view;
61
+ const onViewChangeRef = useRef(onViewChange);
62
+ onViewChangeRef.current = onViewChange;
63
+ const onTapRef = useRef(onTap);
64
+ onTapRef.current = onTap;
51
65
  // Captured once, from the view the canvas opened at — not the current view,
52
66
  // which would let a prior zoom-out shrink the range and trap the opening
53
67
  // view unreachable behind it.
@@ -74,44 +88,48 @@ export function usePanZoom({
74
88
  [onViewChange, minZoom, maxZoom, frame],
75
89
  );
76
90
 
77
- const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
78
- if (e.button !== 0) return;
79
- e.currentTarget.setPointerCapture(e.pointerId);
80
- dragRef.current = {
81
- pointerId: e.pointerId,
82
- startScreenX: e.clientX,
83
- startScreenY: e.clientY,
84
- startPan: { ...viewRef.current.pan },
85
- moved: false,
86
- };
91
+ const clearDrag = useCallback(() => {
92
+ dragRef.current = null;
93
+ sessionRef.current = null;
87
94
  }, []);
88
95
 
89
- const onPointerMove = useCallback(
90
- (e: PointerEvent<HTMLElement>) => {
91
- const drag = dragRef.current;
92
- if (!drag || drag.pointerId !== e.pointerId) return;
93
- const dx = e.clientX - drag.startScreenX;
94
- const dy = e.clientY - drag.startScreenY;
95
- if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
96
- drag.moved = true;
97
- onViewChange({
98
- zoom: viewRef.current.zoom,
99
- pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
96
+ const onPointerDown = useCallback(
97
+ (e: ReactPointerEvent<HTMLElement>) => {
98
+ if (e.button !== 0) return;
99
+ const drag: DragState = {
100
+ startScreenX: e.clientX,
101
+ startScreenY: e.clientY,
102
+ startPan: { ...viewRef.current.pan },
103
+ moved: false,
104
+ };
105
+ dragRef.current = drag;
106
+ sessionRef.current = openPointerSession(e.currentTarget, e, {
107
+ onMove: (ev) => {
108
+ const dx = ev.clientX - drag.startScreenX;
109
+ const dy = ev.clientY - drag.startScreenY;
110
+ if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
111
+ drag.moved = true;
112
+ onViewChangeRef.current({
113
+ zoom: viewRef.current.zoom,
114
+ pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },
115
+ });
116
+ },
117
+ onEnd: (ev) => {
118
+ const tapped = !drag.moved;
119
+ clearDrag();
120
+ if (tapped) onTapRef.current?.(ev);
121
+ },
122
+ // A cancelled pan keeps whatever it had already applied: every move
123
+ // was committed as it arrived, so there is nothing left to undo.
124
+ onCancel: clearDrag,
100
125
  });
101
126
  },
102
- [onViewChange],
127
+ [clearDrag],
103
128
  );
104
129
 
105
- const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
106
- const drag = dragRef.current;
107
- if (!drag || drag.pointerId !== e.pointerId) return;
108
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
109
- e.currentTarget.releasePointerCapture(e.pointerId);
110
- }
111
- dragRef.current = null;
112
- }, []);
130
+ useEffect(() => () => sessionRef.current?.cancel(), []);
113
131
 
114
132
  const isDragging = useCallback(() => dragRef.current?.moved === true, []);
115
133
 
116
- return { onWheel, onPointerDown, onPointerMove, onPointerUp, isDragging };
134
+ return { onWheel, onPointerDown, isDragging };
117
135
  }
@@ -29,6 +29,11 @@ describe('builder', () => {
29
29
  });
30
30
  });
31
31
 
32
+ it('annotates a number with a display suffix', () => {
33
+ const n = f.number(20).suffix('px');
34
+ expect(n.annotations.suffix).toBe('px');
35
+ });
36
+
32
37
  it('keeps a custom node kind across a chain', () => {
33
38
  const n = f.custom('vector2', { x: 0 }).label('Offset');
34
39
  expect(n.kind).toBe('vector2');
@@ -90,6 +90,12 @@ class NumberNode extends BaseNode<number> {
90
90
  return this.ann({ step });
91
91
  }
92
92
 
93
+ /** A display suffix for the value — `'px'`, `'ms'`, `'°'`. Presentation only:
94
+ * it is never parsed, and the stored value stays a plain number. */
95
+ suffix(suffix: string): this {
96
+ return this.ann({ suffix });
97
+ }
98
+
93
99
  /** Force a slider even without both bounds. */
94
100
  slider(): this {
95
101
  return this.ann({ control: 'slider' });
@@ -24,6 +24,7 @@ export interface Annotations {
24
24
  min?: number;
25
25
  max?: number;
26
26
  step?: number;
27
+ suffix?: string;
27
28
  control?: string;
28
29
  options?: readonly ConfigOption[];
29
30
  placeholder?: string;
@@ -420,3 +420,26 @@ describe('<ControlPanel> packing', () => {
420
420
  expect(rowOf('Width')?.className).toMatch(/rowInline/);
421
421
  });
422
422
  });
423
+
424
+ describe('<ControlPanel> suffix', () => {
425
+ const panel = (node: ReturnType<typeof f.number>) =>
426
+ render(
427
+ <ControlPanel
428
+ schema={resolveConfigSchema(f.schema({ width: node.label('Width') }), [])}
429
+ config={{ width: 20 }}
430
+ setConfig={vi.fn()}
431
+ />,
432
+ );
433
+
434
+ it('suffixes a slider readout with the suffix the leaf declares', () => {
435
+ const { container } = panel(f.number(20).range(0, 100).slider().suffix('px'));
436
+ expect(container.querySelector('input[type="range"]')).not.toBeNull();
437
+ expect(container.textContent).toContain('px');
438
+ });
439
+
440
+ it('suffixes a typed number with the suffix the leaf declares', () => {
441
+ const { container } = panel(f.number(20).input().suffix('px'));
442
+ expect(container.querySelector('input[type="number"]')).not.toBeNull();
443
+ expect(container.textContent).toContain('px');
444
+ });
445
+ });
@@ -159,6 +159,7 @@ function ControlRow<TC extends Record<string, unknown>>({
159
159
  const min = extra<number>(leaf, 'min');
160
160
  const max = extra<number>(leaf, 'max');
161
161
  const step = extra<number>(leaf, 'step');
162
+ const suffix = extra<string>(leaf, 'suffix');
162
163
  if (extra<string>(leaf, 'control') === 'slider' && min !== undefined && max !== undefined) {
163
164
  return (
164
165
  <SliderRow
@@ -167,6 +168,7 @@ function ControlRow<TC extends Record<string, unknown>>({
167
168
  min={min}
168
169
  max={max}
169
170
  step={step}
171
+ unit={suffix}
170
172
  onChange={write}
171
173
  layout={layout}
172
174
  span={wide}
@@ -186,6 +188,7 @@ function ControlRow<TC extends Record<string, unknown>>({
186
188
  min={min}
187
189
  max={max}
188
190
  step={step}
191
+ unit={suffix}
189
192
  onChange={(n) => write(Math.min(hi, Math.max(lo, n)))}
190
193
  layout={layout}
191
194
  description={description}
package/src/job/useJob.ts CHANGED
@@ -111,7 +111,7 @@ export function useJob<TS, TC, TItem>({
111
111
  lastKey.current = key;
112
112
  started.current = true;
113
113
  start();
114
- }, [auto, key, start]); // eslint-disable-line react-hooks/exhaustive-deps
114
+ }, [auto, key, start]);
115
115
 
116
116
  useEffect(
117
117
  () => () => {
@@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
12
12
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { defineInstrument } from '../instrument/defineInstrument';
14
14
  import { SurfaceContext } from '../surface/SurfaceContext';
15
- import { useSurfaceOptional, useSurfaceTile } from '../surface/useSurfaceTile';
15
+ import { useSurfaceOptional, useSurfaceTile, useTileId } from '../surface/useSurfaceTile';
16
16
  import type { SurfaceFrame, SurfaceHandle } from '../surface/useTiledSurface';
17
17
  import { useTiledSurface } from '../surface/useTiledSurface';
18
18
  import { Lab } from './Lab';
@@ -39,9 +39,13 @@ beforeAll(() => {
39
39
  });
40
40
 
41
41
  let seen: SurfaceHandle | null | 'not-rendered' = 'not-rendered';
42
+ /** The key the probe's tile is actually registered under: a tile inside a
43
+ * trial is scoped by it, and the trial's id is minted by the store. */
44
+ let paneTileId = 'pane';
42
45
 
43
46
  function Probe() {
44
47
  seen = useSurfaceOptional();
48
+ paneTileId = useTileId('pane');
45
49
  const tile = useSurfaceTile('pane');
46
50
  return <div data-testid="pane" ref={tile} />;
47
51
  }
@@ -146,7 +150,7 @@ describe('<Lab> tile round trip', () => {
146
150
 
147
151
  act(() => {
148
152
  handle?.invalidateRects();
149
- handle?.invalidate('pane');
153
+ handle?.invalidate(paneTileId);
150
154
  });
151
155
  act(() => {
152
156
  vi.advanceTimersByTime(64);
@@ -156,7 +160,8 @@ describe('<Lab> tile round trip', () => {
156
160
  // that passes because nothing ran is the failure mode this arc keeps
157
161
  // hitting.
158
162
  expect(frames.length).toBeGreaterThan(0);
159
- const rect = frames[frames.length - 1]?.rects.get('pane');
163
+ expect(paneTileId).not.toBe('pane');
164
+ const rect = frames[frames.length - 1]?.rects.get(paneTileId);
160
165
  expect(rect).toEqual({ x: 240, y: 100, w: 320, h: 200 });
161
166
  });
162
167
  });
@@ -39,4 +39,16 @@ describe('<LabHeader>', () => {
39
39
  expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
40
40
  }
41
41
  });
42
+
43
+ it('draws each color mode as a glyph, with the word only as its name', () => {
44
+ // The assertion above passes whether the segment holds text or an icon,
45
+ // because both routes produce the same accessible name. This is the one
46
+ // that can tell them apart.
47
+ render(<Lab instruments={[Stub]} defaultInstrument="Stub" storage={null} />);
48
+ for (const label of ['Auto', 'Light', 'Dark']) {
49
+ const segment = screen.getByRole('radio', { name: label });
50
+ expect(segment.querySelector('svg')).not.toBeNull();
51
+ expect(segment).toHaveTextContent('');
52
+ }
53
+ });
42
54
  });
@@ -1,14 +1,22 @@
1
- import { AddIcon } from '@weasel-js/ui';
1
+ import { AddIcon, ModeAutoIcon, ModeDarkIcon, ModeLightIcon } from '@weasel-js/ui';
2
2
  import { Select, ToggleBar } from '../passthrough/weasel-ui';
3
3
  import type { LabMode } from '../state/types';
4
4
  import { useLabContext } from './LabContext';
5
5
 
6
- const MODES: { value: LabMode; label: string }[] = [
7
- { value: 'auto', label: 'Auto' },
8
- { value: 'light', label: 'Light' },
9
- { value: 'dark', label: 'Dark' },
6
+ // The glyph is the segment's content and the word is its accessible name, so
7
+ // the bar stays a three-way radiogroup announcing Auto / Light / Dark.
8
+ const MODES = [
9
+ { value: 'auto' as LabMode, label: 'Auto', glyph: <ModeAutoIcon size={14} /> },
10
+ { value: 'light' as LabMode, label: 'Light', glyph: <ModeLightIcon size={14} /> },
11
+ { value: 'dark' as LabMode, label: 'Dark', glyph: <ModeDarkIcon size={14} /> },
10
12
  ];
11
13
 
14
+ const MODE_ITEMS = MODES.map(({ value, label, glyph }) => ({
15
+ value,
16
+ label: glyph,
17
+ ariaLabel: label,
18
+ }));
19
+
12
20
  /** The controls `<Lab>` puts in its header: add a trial, and choose the color
13
21
  * mode. Both drive `LabContext`, which carried them with no UI at all — so
14
22
  * every consumer rebuilt these two. Rendered before a consumer's own header
@@ -46,7 +54,9 @@ export function LabHeader() {
46
54
  <ToggleBar
47
55
  className="lk-lab-header__mode"
48
56
  ariaLabel="Color mode"
49
- items={MODES}
57
+ size="sm"
58
+ variant="flat"
59
+ items={MODE_ITEMS}
50
60
  value={lab.mode}
51
61
  onChange={(next) => {
52
62
  if (next) lab.setMode(next);
@@ -210,7 +210,7 @@ export function Workspace({
210
210
  if (!surface) return;
211
211
  surface.invalidateRects();
212
212
  return store.events.on('node.placementChanged', () => surface.invalidateRects());
213
- }, [store, surface, nodeIds]); // eslint-disable-line react-hooks/exhaustive-deps
213
+ }, [store, surface, nodeIds]);
214
214
 
215
215
  useEffect(() => {
216
216
  if (!onLayoutChange) return;
@@ -1,4 +1,4 @@
1
- import { fireEvent, render, screen } from '@testing-library/react';
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it, vi } from 'vitest';
3
3
  import type { LayerDescriptor } from '../instrument/types';
4
4
  import { LayerList } from './LayerList';
@@ -47,3 +47,74 @@ describe('<LayerList>', () => {
47
47
  expect(onToggle).toHaveBeenCalledWith('a', false);
48
48
  });
49
49
  });
50
+
51
+ const twoLayers: LayerDescriptor[] = [
52
+ { id: 'a', label: 'A' },
53
+ { id: 'b', label: 'B' },
54
+ ];
55
+
56
+ function dispatchOn(target: EventTarget, type: string, init: PointerEventInit) {
57
+ act(() => {
58
+ target.dispatchEvent(new PointerEvent(type, { pointerId: 1, bubbles: true, ...init }));
59
+ });
60
+ }
61
+
62
+ /** jsdom measures every row at zero height, so `startDrag` falls back to a
63
+ * pitch of 1: one pixel of travel is one row. */
64
+ function renderDraggable(onReorder = vi.fn()) {
65
+ const result = render(
66
+ <LayerList
67
+ layers={twoLayers}
68
+ visibility={{ a: true, b: true }}
69
+ onReorder={onReorder}
70
+ onToggle={vi.fn()}
71
+ />,
72
+ );
73
+ return { ...result, onReorder, handle: screen.getByLabelText('Reorder A') };
74
+ }
75
+
76
+ describe('<LayerList> reordering', () => {
77
+ it('reorders from a release that lands off the handle', () => {
78
+ const { onReorder, handle } = renderDraggable();
79
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
80
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
81
+ dispatchOn(document, 'pointerup', { clientY: 1 });
82
+ expect(onReorder).toHaveBeenCalledTimes(1);
83
+ expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
84
+ });
85
+
86
+ it('abandons the reorder when capture is lost mid-drag', () => {
87
+ const { onReorder, handle, container } = renderDraggable();
88
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
89
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
90
+ dispatchOn(handle, 'lostpointercapture', {});
91
+ expect(onReorder).not.toHaveBeenCalled();
92
+ expect(container.querySelector('.lk-layer-list__row--dragging')).toBeNull();
93
+ });
94
+
95
+ it('reads a move with nothing held as the release that never arrived', () => {
96
+ const { onReorder, handle } = renderDraggable();
97
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
98
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
99
+ dispatchOn(document, 'pointermove', { buttons: 0, clientY: 40 });
100
+ expect(onReorder).toHaveBeenCalledTimes(1);
101
+ expect(onReorder.mock.calls[0][0].map((l: LayerDescriptor) => l.id)).toEqual(['b', 'a']);
102
+ });
103
+
104
+ it('drops its listeners when the list unmounts mid-drag', () => {
105
+ const { onReorder, handle, unmount } = renderDraggable();
106
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
107
+ dispatchOn(document, 'pointermove', { buttons: 1, clientY: 1 });
108
+ unmount();
109
+ dispatchOn(document, 'pointerup', { clientY: 1 });
110
+ expect(onReorder).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('does not capture a second pointer into the same drag', () => {
114
+ const { onReorder, handle } = renderDraggable();
115
+ fireEvent.pointerDown(handle, { pointerId: 1, buttons: 1, clientY: 0 });
116
+ dispatchOn(document, 'pointermove', { pointerId: 2, buttons: 1, clientY: 40 });
117
+ dispatchOn(document, 'pointerup', { pointerId: 1, clientY: 0 });
118
+ expect(onReorder).not.toHaveBeenCalled();
119
+ });
120
+ });
@@ -1,4 +1,5 @@
1
- import { type PointerEvent, useRef, useState } from 'react';
1
+ import { openPointerSession, type PointerSession } from '@weasel-js/core';
2
+ import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react';
2
3
  import type { LayerDescriptor } from '../instrument/types';
3
4
  import { DragHandleGlyph } from '../passthrough/weasel-ui';
4
5
 
@@ -12,8 +13,8 @@ export interface LayerListProps {
12
13
  }
13
14
 
14
15
  interface DragState {
15
- pointerId: number;
16
16
  fromIndex: number;
17
+ toIndex: number;
17
18
  startY: number;
18
19
  /** Row height plus row gap, measured when the drag starts. */
19
20
  pitch: number;
@@ -26,6 +27,13 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
26
27
  const pinned = layers.filter((l) => l.alwaysOn);
27
28
  const [dragIndex, setDragIndex] = useState<number | null>(null);
28
29
  const dragRef = useRef<DragState | null>(null);
30
+ const sessionRef = useRef<PointerSession | null>(null);
31
+ // The session's callbacks outlive the render that opened them, so the commit
32
+ // reads its inputs here rather than from that render's closure.
33
+ const commitRef = useRef({ reorderable, pinned, onReorder });
34
+ commitRef.current = { reorderable, pinned, onReorder };
35
+
36
+ useEffect(() => () => sessionRef.current?.cancel(), []);
29
37
 
30
38
  if (layers.length === 0) {
31
39
  return (
@@ -35,45 +43,47 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
35
43
  );
36
44
  }
37
45
 
38
- const startDrag = (e: PointerEvent<HTMLElement>, index: number) => {
39
- e.currentTarget.setPointerCapture(e.pointerId);
46
+ const clearDrag = () => {
47
+ dragRef.current = null;
48
+ sessionRef.current = null;
49
+ setDragIndex(null);
50
+ };
51
+
52
+ const startDrag = (e: ReactPointerEvent<HTMLElement>, index: number) => {
40
53
  // Measured at grab time rather than hardcoded: a restyle that changes row
41
54
  // height or gap would otherwise silently skew every drag distance.
42
55
  const row = e.currentTarget.closest('.lk-layer-list__row');
43
56
  const list = row?.parentElement;
44
57
  const gap = list ? Number.parseFloat(getComputedStyle(list).rowGap) || 0 : 0;
45
58
  const pitch = row ? row.getBoundingClientRect().height + gap : 0;
46
- dragRef.current = {
47
- pointerId: e.pointerId,
59
+ const drag: DragState = {
48
60
  fromIndex: index,
61
+ toIndex: index,
49
62
  startY: e.clientY,
50
63
  pitch: pitch > 0 ? pitch : 1,
51
64
  };
65
+ dragRef.current = drag;
52
66
  setDragIndex(index);
53
- };
54
-
55
- const moveDrag = (e: PointerEvent<HTMLElement>) => {
56
- const drag = dragRef.current;
57
- if (!drag || drag.pointerId !== e.pointerId) return;
58
- const delta = Math.round((e.clientY - drag.startY) / drag.pitch);
59
- const target = Math.min(reorderable.length - 1, Math.max(0, drag.fromIndex + delta));
60
- setDragIndex(target);
61
- };
62
-
63
- const endDrag = (e: PointerEvent<HTMLElement>) => {
64
- const drag = dragRef.current;
65
- if (!drag || drag.pointerId !== e.pointerId) return;
66
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
67
- e.currentTarget.releasePointerCapture(e.pointerId);
68
- }
69
- if (dragIndex !== null && dragIndex !== drag.fromIndex) {
70
- const next = [...reorderable];
71
- const [moved] = next.splice(drag.fromIndex, 1);
72
- if (moved) next.splice(dragIndex, 0, moved);
73
- onReorder([...next, ...pinned]);
74
- }
75
- dragRef.current = null;
76
- setDragIndex(null);
67
+ sessionRef.current = openPointerSession(e.currentTarget, e, {
68
+ onMove: (ev) => {
69
+ const delta = Math.round((ev.clientY - drag.startY) / drag.pitch);
70
+ const last = commitRef.current.reorderable.length - 1;
71
+ drag.toIndex = Math.min(last, Math.max(0, drag.fromIndex + delta));
72
+ setDragIndex(drag.toIndex);
73
+ },
74
+ onEnd: () => {
75
+ const { reorderable: rows, pinned: locked, onReorder: commit } = commitRef.current;
76
+ const { fromIndex, toIndex } = drag;
77
+ clearDrag();
78
+ if (toIndex === fromIndex) return;
79
+ const next = [...rows];
80
+ const [moved] = next.splice(fromIndex, 1);
81
+ if (moved) next.splice(toIndex, 0, moved);
82
+ commit([...next, ...locked]);
83
+ },
84
+ // An interrupted drag never named a destination, so it reorders nothing.
85
+ onCancel: clearDrag,
86
+ });
77
87
  };
78
88
 
79
89
  return (
@@ -92,8 +102,6 @@ export function LayerList({ layers, visibility, onReorder, onToggle, className }
92
102
  className="lk-layer-list__handle"
93
103
  aria-label={`Reorder ${layer.label}`}
94
104
  onPointerDown={(e) => startDrag(e, i)}
95
- onPointerMove={moveDrag}
96
- onPointerUp={endDrag}
97
105
  >
98
106
  <DragHandleGlyph size={13} />
99
107
  </button>