@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
@@ -8,6 +8,8 @@ import {
8
8
  Dialog,
9
9
  Field,
10
10
  formatNumber,
11
+ ICON_PATHS,
12
+ Icon,
11
13
  Input,
12
14
  KeyCap,
13
15
  MINUS_SIGN,
@@ -69,6 +71,13 @@ describe('weasel-ui passthrough', () => {
69
71
  }
70
72
  });
71
73
 
74
+ it('re-exports the icon set, so a lab need not depend on weasel-ui itself', () => {
75
+ expect(['function', 'object']).toContain(typeof Icon);
76
+ expect(ICON_PATHS.layoutRows).toContain('<rect');
77
+ expect(ICON_PATHS.layoutColumns).toContain('<rect');
78
+ expect(ICON_PATHS.layoutGrid).toContain('<rect');
79
+ });
80
+
72
81
  it('re-exports app-chrome primitives as React components', () => {
73
82
  for (const C of [Sidebar, ToolPalette, KeyCap]) {
74
83
  expect(C).toBeDefined();
@@ -69,6 +69,10 @@ export {
69
69
  formatShortcutParts,
70
70
  type GradientTrackOpts,
71
71
  type GridSettings,
72
+ ICON_PATHS,
73
+ Icon,
74
+ type IconName,
75
+ type IconProps,
72
76
  Input,
73
77
  type InputProps,
74
78
  type InterpolationMode,
@@ -313,3 +313,85 @@ describe('a clickable row nested in a list, the shape a floating panel usually h
313
313
  expect(panel.dataset.dragging).toBe('true');
314
314
  });
315
315
  });
316
+
317
+ describe('FloatingPanel drag lifecycle', () => {
318
+ const realCapture = Element.prototype.setPointerCapture;
319
+ afterEach(() => {
320
+ Element.prototype.setPointerCapture = realCapture;
321
+ });
322
+
323
+ it('keeps dragging from moves the panel never sees, and ends on a release off it', () => {
324
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
325
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
326
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
327
+ expect(panel.dataset.dragging).toBe('true');
328
+ expect(panel.style.left).toBe('100px');
329
+ fireEvent.pointerUp(document.body, { pointerId: 1, clientX: 200, clientY: 180 });
330
+ expect(panel.dataset.dragging).toBeUndefined();
331
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
332
+ expect(panel.style.left).toBe('100px');
333
+ });
334
+
335
+ it('ends the drag when capture is lost mid-gesture', () => {
336
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
337
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
338
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
339
+ fireEvent(panel, new PointerEvent('lostpointercapture', { pointerId: 1, bubbles: true }));
340
+ expect(panel.dataset.dragging).toBeUndefined();
341
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 300, clientY: 180 });
342
+ expect(panel.style.left).toBe('100px');
343
+ });
344
+
345
+ it('reads a move with nothing held as the release that never arrived', () => {
346
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
347
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
348
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 1, clientX: 200, clientY: 180 });
349
+ expect(panel.dataset.dragging).toBe('true');
350
+ fireEvent.pointerMove(document.body, { pointerId: 1, buttons: 0, clientX: 300, clientY: 180 });
351
+ expect(panel.dataset.dragging).toBeUndefined();
352
+ expect(panel.style.left).toBe('100px');
353
+ });
354
+
355
+ it('captures the pointer only once the press crosses the drag threshold', () => {
356
+ // A proxy for the real symptom: jsdom's setPointerCapture never retargets,
357
+ // so the child click a capture-on-press would swallow cannot be observed.
358
+ const captured: number[] = [];
359
+ Element.prototype.setPointerCapture = (id: number) => {
360
+ captured.push(id);
361
+ };
362
+ const { panel } = renderPanel(<FloatingPanel anchor="top-left">x</FloatingPanel>);
363
+ fireEvent.pointerDown(panel, { pointerId: 7, buttons: 1, clientX: 100, clientY: 100 });
364
+ expect(captured).toEqual([]);
365
+ fireEvent.pointerMove(document.body, { pointerId: 7, buttons: 1, clientX: 140, clientY: 100 });
366
+ expect(captured).toEqual([7]);
367
+ });
368
+
369
+ it('drops its document listeners when the panel unmounts mid-drag', () => {
370
+ const added: string[] = [];
371
+ const removed: string[] = [];
372
+ const realAdd = document.addEventListener.bind(document);
373
+ const realRemove = document.removeEventListener.bind(document);
374
+ const addSpy = vi
375
+ .spyOn(document, 'addEventListener')
376
+ .mockImplementation((type, listener, opts) => {
377
+ added.push(String(type));
378
+ realAdd(type as never, listener as never, opts as never);
379
+ });
380
+ const removeSpy = vi
381
+ .spyOn(document, 'removeEventListener')
382
+ .mockImplementation((type, listener, opts) => {
383
+ removed.push(String(type));
384
+ realRemove(type as never, listener as never, opts as never);
385
+ });
386
+ try {
387
+ const { panel, unmount } = renderPanel(<FloatingPanel>x</FloatingPanel>);
388
+ fireEvent.pointerDown(panel, { pointerId: 1, buttons: 1, clientX: 100, clientY: 100 });
389
+ expect(added).toContain('pointermove');
390
+ unmount();
391
+ expect(removed).toContain('pointermove');
392
+ } finally {
393
+ addSpy.mockRestore();
394
+ removeSpy.mockRestore();
395
+ }
396
+ });
397
+ });
@@ -1,3 +1,4 @@
1
+ import { openPointerSession, type PointerSession } from '@weasel-js/core';
1
2
  import type React from 'react';
2
3
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
3
4
  import {
@@ -130,67 +131,84 @@ export function FloatingPanel({
130
131
  .placements.get(ITEM_ID);
131
132
 
132
133
  const dragging = useRef<{ x: number; y: number } | null>(null);
133
- /** Pressed but not yet moved far enough to be a drag. */
134
- const pending = useRef<{ x: number; y: number; pointerId: number } | null>(null);
134
+ const session = useRef<PointerSession | null>(null);
135
135
  const [isDragging, setDragging] = useState(false);
136
+ // A drag outlives the render that started it, so the reducer's inputs are
137
+ // read here and not from that render's closure — a container resized
138
+ // mid-drag would otherwise place the panel against its old box.
139
+ const layout = useRef({ container, options, item });
140
+ layout.current = { container, options, item };
141
+
142
+ useEffect(() => () => session.current?.cancel(), []);
136
143
 
137
144
  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
138
145
  if (isInteractive(e.target)) return;
139
146
  // The canvas stack underneath owns pan/zoom on the same pointer events.
140
147
  e.stopPropagation();
148
+ const origin = e.currentTarget;
149
+ const pointerId = e.pointerId;
150
+ const start = { x: e.clientX, y: e.clientY };
151
+
152
+ const end = () => {
153
+ session.current = null;
154
+ if (!dragging.current) return;
155
+ dragging.current = null;
156
+ setDragging(false);
157
+ try {
158
+ origin.releasePointerCapture?.(pointerId);
159
+ } catch {
160
+ // Capture is already gone whenever the session ended by losing it.
161
+ }
162
+ };
163
+
141
164
  // Armed, not capturing. Capture retargets mouseup to this element, and the
142
165
  // browser then synthesizes no `click` on the child under the cursor — so
143
166
  // capturing on press makes every non-native control in the panel dead to a
144
167
  // real mouse, silently. `isInteractive` only exempts the names it lists; a
145
168
  // `role="button"` span, a react-aria control or a canvas is not among them.
146
- pending.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
147
- };
148
-
149
- const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
150
- const armed = pending.current;
151
- if (armed && armed.pointerId === e.pointerId && !dragging.current) {
152
- if (Math.hypot(e.clientX - armed.x, e.clientY - armed.y) < DRAG_THRESHOLD) return;
153
- dragging.current = { x: armed.x, y: armed.y };
154
- setDragging(true);
155
- e.currentTarget.setPointerCapture?.(e.pointerId);
156
- }
157
- const from = dragging.current;
158
- if (!from) return;
159
- const dx = e.clientX - from.x;
160
- const dy = e.clientY - from.y;
161
- if (dx === 0 && dy === 0) return;
162
- dragging.current = { x: e.clientX, y: e.clientY };
163
- setPlace(
164
- (prev) =>
165
- strategy.reduce?.(
166
- { at: { [ITEM_ID]: prev }, inner: undefined },
167
- {
168
- affordanceId: `${FLOATING_DRAG_PREFIX}${ITEM_ID}`,
169
- kind: 'drag',
170
- payload: { dx, dy },
171
- },
172
- { container, options, items: [item] },
173
- ).at[ITEM_ID] ?? prev,
169
+ session.current = openPointerSession(
170
+ origin,
171
+ e,
172
+ {
173
+ onMove: (ev) => {
174
+ if (!dragging.current) {
175
+ if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
176
+ dragging.current = { ...start };
177
+ setDragging(true);
178
+ origin.setPointerCapture?.(pointerId);
179
+ }
180
+ const from = dragging.current;
181
+ const dx = ev.clientX - from.x;
182
+ const dy = ev.clientY - from.y;
183
+ if (dx === 0 && dy === 0) return;
184
+ dragging.current = { x: ev.clientX, y: ev.clientY };
185
+ const { container: box, options: opts, item: only } = layout.current;
186
+ setPlace(
187
+ (prev) =>
188
+ strategy.reduce?.(
189
+ { at: { [ITEM_ID]: prev }, inner: undefined },
190
+ {
191
+ affordanceId: `${FLOATING_DRAG_PREFIX}${ITEM_ID}`,
192
+ kind: 'drag',
193
+ payload: { dx, dy },
194
+ },
195
+ { container: box, options: opts, items: [only] },
196
+ ).at[ITEM_ID] ?? prev,
197
+ );
198
+ },
199
+ onEnd: end,
200
+ onCancel: end,
201
+ },
202
+ { capture: false },
174
203
  );
175
204
  };
176
205
 
177
- const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
178
- pending.current = null;
179
- if (!dragging.current) return;
180
- dragging.current = null;
181
- setDragging(false);
182
- e.currentTarget.releasePointerCapture?.(e.pointerId);
183
- };
184
-
185
206
  return (
186
207
  <div
187
208
  className={className ? `lk-floating-panel ${className}` : 'lk-floating-panel'}
188
209
  data-dragging={isDragging ? 'true' : undefined}
189
210
  data-placed={rect ? 'true' : undefined}
190
- onPointerCancel={endDrag}
191
211
  onPointerDown={onPointerDown}
192
- onPointerMove={onPointerMove}
193
- onPointerUp={endDrag}
194
212
  ref={ref}
195
213
  style={
196
214
  {
@@ -45,6 +45,7 @@ export function ZoomControl({
45
45
  <Slider
46
46
  className="lk-zoom__slider"
47
47
  ariaLabel="Zoom"
48
+ density="slim"
48
49
  min={toTrack(min)}
49
50
  max={toTrack(max)}
50
51
  // Detents at the octaves plus actual size, so 25/50/100/200/400% are
@@ -65,6 +66,7 @@ export function ZoomControl({
65
66
  maxValue={max}
66
67
  step={0.01}
67
68
  hideSteppers
69
+ ghost
68
70
  formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
69
71
  onChange={(next) => {
70
72
  if (!Number.isFinite(next)) return;
@@ -34,6 +34,7 @@ export interface LabStoreActions {
34
34
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
35
35
  updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
36
36
  updateTrialView: (id: string, view: unknown) => void;
37
+ updateTrialSidebarWidth: (id: string, width: number) => void;
37
38
  updateTrialAnnotations: (id: string, doc: unknown) => void;
38
39
  updateTrialUndoStack: (
39
40
  id: string,
@@ -136,6 +137,15 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
136
137
  scheduleFlush();
137
138
  },
138
139
 
140
+ updateTrialSidebarWidth: (id, width) => {
141
+ set((s) => ({
142
+ trials: s.trials.map((w) =>
143
+ w.id === id && w.sidebarWidth !== width ? { ...w, sidebarWidth: width } : w,
144
+ ),
145
+ }));
146
+ scheduleFlush();
147
+ },
148
+
139
149
  updateTrialAnnotations: (id, doc) => {
140
150
  set((s) => ({
141
151
  trials: s.trials.map((w) =>
@@ -18,6 +18,9 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
18
18
  view: TV;
19
19
  /** This trial's own tool slot. Undefined means it reads the lab's. */
20
20
  activeToolId?: string | null;
21
+ /** The extent the trial's sidebar was last dragged to, in pixels. Undefined
22
+ * until someone moves the seam. */
23
+ sidebarWidth?: number;
21
24
  /** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
22
25
  * wrote them. Opaque here, and absent for a trial whose instrument declares
23
26
  * no `annotations` — or declares its own `storage`. Not in `state`, which
@@ -17,7 +17,7 @@ than a few 3D tiles have to share one surface.
17
17
  | `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
18
18
  | `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
19
19
  | `SurfaceContext.ts` | Carries the handle down |
20
- | `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useSurface()`, `useSurfaceOptional()` |
20
+ | `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useTileId(id)`, `useSurface()`, `useSurfaceOptional()` |
21
21
 
22
22
  ## Shape of a consumer
23
23
 
@@ -40,6 +40,16 @@ const surface = useTiledSurface({
40
40
  draw has to know where it is drawing relative to a surface that may have resized
41
41
  under it.
42
42
 
43
+ ## A tile id is scoped to its trial
44
+
45
+ The tile namespace is one lab-wide map, but an instrument names its regions once
46
+ and every trial of it declares those same names. `useSurfaceTile` registers under
47
+ `useTileId(id)` — `<trial>/<id>` inside a trial — and a frame's `rects` are keyed
48
+ the same way, so a host looking a tile up calls `useTileId` for the key. Unscoped,
49
+ the second trial to mount takes the first one's rect *and* its painter, and the
50
+ first is never told it moved again: it keeps the box it was measured at while it
51
+ was the only trial open.
52
+
43
53
  ## The unit is a rect, not a trial
44
54
 
45
55
  `useSurfaceTile(id)` attaches to whatever element the surface should draw into. A
@@ -2,7 +2,13 @@ export { composeRects, rectsEqual } from './composeRects';
2
2
  export { toDeviceRect } from './deviceRect';
3
3
  export type { Box, Rect } from './rect';
4
4
  export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
5
- export { useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
5
+ export {
6
+ useSurface,
7
+ useSurfaceCanvas,
8
+ useSurfaceOptional,
9
+ useSurfaceTile,
10
+ useTileId,
11
+ } from './useSurfaceTile';
6
12
  export type {
7
13
  SurfaceFrame,
8
14
  SurfaceHandle,
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useContext } from 'react';
2
+ import { TrialIdContext } from '../state/context';
2
3
  import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
3
4
  import type { SurfaceHandle } from './useTiledSurface';
4
5
 
@@ -20,19 +21,36 @@ export function useSurface(): SurfaceHandle {
20
21
  return surface;
21
22
  }
22
23
 
24
+ /**
25
+ * The key `id` is registered under, and so the key it comes back on in a
26
+ * frame's `rects`: `<trial>/<id>` inside a trial, and `id` alone outside one.
27
+ *
28
+ * A surface's tile namespace is flat and shared by the whole lab, but an
29
+ * instrument names its regions once and every trial of it declares those same
30
+ * names. Unscoped, the second trial to mount takes the first one's rect and its
31
+ * painter, and the first is never told it moved again.
32
+ */
33
+ export function useTileId(id: string): string {
34
+ const trial = useContext(TrialIdContext);
35
+ return trial ? `${trial}/${id}` : id;
36
+ }
37
+
23
38
  /**
24
39
  * A ref callback that publishes this element's rect to the surface under `id`.
25
40
  *
26
41
  * Attach it to whichever element the surface should draw into — that is not
27
42
  * necessarily the trial's own element, since a trial may hold a drawn pane beside
28
43
  * an undrawn one, or none at all.
44
+ *
45
+ * The frame reports it under `useTileId(id)`, not under `id`.
29
46
  */
30
47
  export function useSurfaceTile(id: string): (el: HTMLElement | null) => void {
31
48
  const surface = useSurfaceOptional();
49
+ const tileId = useTileId(id);
32
50
  return useCallback(
33
51
  (el: HTMLElement | null) => {
34
- surface?.registerTile(id, el);
52
+ surface?.registerTile(tileId, el);
35
53
  },
36
- [surface, id],
54
+ [surface, tileId],
37
55
  );
38
56
  }
@@ -5,7 +5,10 @@ import type { Box, Rect } from './rect';
5
5
 
6
6
  /** What a surface owner is handed once per animation frame. `rects` carries every
7
7
  * tile, not only the dirty ones: a scissored draw has to know where it is drawing
8
- * relative to a surface that may have resized under it. */
8
+ * relative to a surface that may have resized under it.
9
+ *
10
+ * Its keys are what `useTileId` returns — `<trial>/<id>` for a tile registered
11
+ * inside a trial — not the bare id the tile was named with. */
9
12
  export interface SurfaceFrame {
10
13
  dirty: ReadonlySet<string>;
11
14
  rects: ReadonlyMap<string, Rect>;
@@ -91,11 +91,12 @@ function SwatchGrid({ tokens, title }: { tokens: readonly Swatch[]; title: strin
91
91
  function NebulaPreview() {
92
92
  return (
93
93
  <div>
94
- <h3 className="lk-theme-doc__heading">Cosmic backdrop (`--wzl-backdrop`)</h3>
94
+ <h3 className="lk-theme-doc__heading">Cosmic backdrop</h3>
95
95
  <div className="lk-theme-doc__nebula" style={{ background: 'var(--wzl-backdrop)' }} />
96
96
  <p className="lk-theme-doc__note">
97
- Default: four radial gradients (purple, pink, blue, then a dark void base). Override per-Lab
98
- by passing <code>nebula={'{[colors]}'}</code> to the <code>&lt;Lab&gt;</code> component.
97
+ <code>--wzl-backdrop</code> — four radial gradients (purple, pink, blue) over a dark void
98
+ base. Override per-Lab by passing <code>nebula={'{[colors]}'}</code> to the{' '}
99
+ <code>&lt;Lab&gt;</code> component.
99
100
  </p>
100
101
  </div>
101
102
  );
@@ -66,8 +66,46 @@
66
66
  }
67
67
  }
68
68
 
69
+ // Mirrors packages/ui/src/components/range.module.css so a consumer's bare
70
+ // range matches the kit's. A weasel-ui component's own module class outranks
71
+ // this, which is what keeps InlineRange's gradient track intact.
69
72
  :where(input[type="range"]) {
70
- accent-color: var(--wzl-accent);
73
+ appearance: none;
74
+ -webkit-appearance: none;
75
+ height: 12px;
76
+ background: transparent;
77
+ cursor: pointer;
78
+
79
+ &::-webkit-slider-runnable-track {
80
+ height: var(--wzl-slider-track-h);
81
+ border-radius: var(--wzl-radius-sm);
82
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
83
+ }
84
+
85
+ &::-moz-range-track {
86
+ height: var(--wzl-slider-track-h);
87
+ border-radius: var(--wzl-radius-sm);
88
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
89
+ }
90
+
91
+ &::-webkit-slider-thumb {
92
+ appearance: none;
93
+ -webkit-appearance: none;
94
+ width: var(--wzl-slider-thumb-size);
95
+ height: var(--wzl-slider-thumb-size);
96
+ border: none;
97
+ border-radius: 50%;
98
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
99
+ margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
100
+ }
101
+
102
+ &::-moz-range-thumb {
103
+ width: var(--wzl-slider-thumb-size);
104
+ height: var(--wzl-slider-thumb-size);
105
+ border: none;
106
+ border-radius: 50%;
107
+ background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
108
+ }
71
109
  }
72
110
  }
73
111
 
@@ -20,13 +20,19 @@
20
20
  overflow: hidden;
21
21
  }
22
22
 
23
+ // The sidebar and the content well are a windease strip, so their extents
24
+ // come from the strategy and this pane only has to fill the rect it was
25
+ // given. `.windease-zone` supplies the containing block.
26
+ &__panes {
27
+ flex: 1;
28
+ min-width: 0;
29
+ }
30
+
23
31
  &__sidebar {
24
- flex-shrink: 0;
25
- // Stated, not content-derived: an auto-width sidebar is as wide as its
26
- // widest label, so one verbose config key sets the width of the lab.
27
- width: var(--lk-trial-sidebar-w, 20rem);
28
32
  display: flex;
29
33
  flex-direction: column;
34
+ width: 100%;
35
+ height: 100%;
30
36
  min-height: 0;
31
37
  // Sections floor at their content height, so without this the last ones
32
38
  // are cut rather than reachable.
@@ -40,7 +46,8 @@
40
46
  &__content {
41
47
  // The viewport region anchors against this well.
42
48
  position: relative;
43
- flex: 1;
49
+ width: 100%;
50
+ height: 100%;
44
51
  min-width: 0;
45
52
  min-height: 0;
46
53
  display: flex;
@@ -89,6 +96,21 @@
89
96
  flex-shrink: 0;
90
97
  }
91
98
 
99
+ // The strip's own seam. windease positions and sizes it; this is only what
100
+ // it looks like.
101
+ &__panes [data-affordance-kind='resize-x'] {
102
+ background: var(--wzl-border);
103
+ }
104
+
105
+ &__panes .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
106
+ &__panes .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
107
+ background: var(--wzl-accent);
108
+ }
109
+
110
+ &__panes .windease-affordance-hit:focus-visible {
111
+ outline: none;
112
+ }
113
+
92
114
  &--unknown {
93
115
  display: flex;
94
116
  align-items: center;
@@ -0,0 +1,67 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { TrialBody } from './TrialBody';
4
+
5
+ const VIEWPORT = { w: 800, h: 600 };
6
+
7
+ function renderBody(props: Partial<Parameters<typeof TrialBody>[0]> = {}) {
8
+ return render(
9
+ <TrialBody viewport={VIEWPORT} sidebar={<p>sidebar body</p>} {...props}>
10
+ <p>instrument output</p>
11
+ </TrialBody>,
12
+ );
13
+ }
14
+
15
+ describe('TrialBody', () => {
16
+ it('renders both panes', () => {
17
+ renderBody();
18
+ expect(screen.getByText('sidebar body')).toBeInTheDocument();
19
+ expect(screen.getByText('instrument output')).toBeInTheDocument();
20
+ });
21
+
22
+ it('puts a labeled separator between the sidebar and the content', () => {
23
+ renderBody({ width: 320 });
24
+ const seam = screen.getByRole('separator');
25
+ expect(seam).toHaveAttribute('aria-orientation', 'horizontal');
26
+ expect(seam).toHaveAttribute('aria-valuenow', '320');
27
+ expect(seam).toHaveAccessibleName(/sidebar/i);
28
+ });
29
+
30
+ it('reports the width a drag lands on', () => {
31
+ const onWidthChange = vi.fn();
32
+ renderBody({ width: 320, onWidthChange });
33
+ const seam = screen.getByRole('separator');
34
+ fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
35
+ fireEvent.pointerMove(seam, { clientX: 380, clientY: 100 });
36
+ fireEvent.pointerUp(seam, { clientX: 380, clientY: 100 });
37
+ expect(onWidthChange).toHaveBeenLastCalledWith(380);
38
+ });
39
+
40
+ it('resizes from the keyboard', () => {
41
+ const onWidthChange = vi.fn();
42
+ renderBody({ width: 320, onWidthChange });
43
+ const seam = screen.getByRole('separator');
44
+ fireEvent.keyDown(seam, { key: 'ArrowRight' });
45
+ expect(onWidthChange).toHaveBeenLastCalledWith(328);
46
+ });
47
+
48
+ it('refuses to drag the sidebar past its stated bounds', () => {
49
+ const onWidthChange = vi.fn();
50
+ renderBody({ width: 320, minWidth: 200, maxWidth: 400, onWidthChange });
51
+ const seam = screen.getByRole('separator');
52
+ expect(seam).toHaveAttribute('aria-valuemin', '200');
53
+ expect(seam).toHaveAttribute('aria-valuemax', '400');
54
+ fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
55
+ fireEvent.pointerMove(seam, { clientX: 900, clientY: 100 });
56
+ expect(onWidthChange).toHaveBeenLastCalledWith(400);
57
+ });
58
+
59
+ it('leaves the content pane a floor the seam cannot cross', () => {
60
+ const onWidthChange = vi.fn();
61
+ renderBody({ width: 320, maxWidth: 10_000, contentMinWidth: 300, onWidthChange });
62
+ const seam = screen.getByRole('separator');
63
+ fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
64
+ fireEvent.pointerMove(seam, { clientX: 5_000, clientY: 100 });
65
+ expect(onWidthChange).toHaveBeenLastCalledWith(VIEWPORT.w - 300);
66
+ });
67
+ });